Compare commits

..

No commits in common. "main" and "feature_update_button" have entirely different histories.

125 changed files with 2554 additions and 11886 deletions

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/TTMT.ManageWebGUI.iml" filepath="$PROJECT_DIR$/.idea/TTMT.ManageWebGUI.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/src/hooks/useAuth.tsx" beforeDir="false" afterPath="$PROJECT_DIR$/src/hooks/useAuth.tsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routeTree.gen.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/routeTree.gen.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/blacklist/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/command/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/$roomName/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/_auth/room/index.tsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/app-sidebar.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/app-sidebar.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/types/permission.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/types/permission.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo"><![CDATA[{
"associatedIndex": 1
}]]></component>
<component name="ProjectId" id="3AQVfIkiaizPRlnpMICDG3COfJV" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"dart.analysis.tool.window.visible": "false",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"javascript.preferred.runtime.type.id": "node",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"ts.external.directory.path": "D:\\MyProject\\NAVISProject\\TTMT.ManageWebGUI\\node_modules\\typescript\\lib",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.31033.133" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="94f75d5b-3c15-4d7e-9da7-ad6f5328faf8" name="Changes" comment="" />
<created>1772524885874</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1772524885874</updated>
<workItem from="1772524887267" duration="1839000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

View File

@ -1,341 +0,0 @@
# TanStack Query Hooks Documentation
Tất cả các API đã được tách riêng thành TanStack Query hooks trong folder `src/hooks/queries/`.
## Cấu trúc
```
src/hooks/queries/
├── index.ts # Export tất cả hooks
├── useAuthQueries.ts # Auth hooks
├── useAppVersionQueries.ts # App/Software hooks
├── useDeviceCommQueries.ts # Device communication hooks
└── useCommandQueries.ts # Command hooks
```
## Cách Sử Dụng
### 1. Auth Queries (Xác thực)
#### Đăng nhập
```tsx
import { useLogin } from '@/hooks/queries'
function LoginPage() {
const loginMutation = useLogin()
const handleLogin = async () => {
try {
await loginMutation.mutateAsync({
username: 'user',
password: 'password'
})
// Tự động lưu token vào localStorage
} catch (error) {
console.error(error)
}
}
return (
<button
onClick={handleLogin}
disabled={loginMutation.isPending}
>
{loginMutation.isPending ? 'Đang đăng nhập...' : 'Đăng nhập'}
</button>
)
}
```
#### Đăng xuất
```tsx
import { useLogout } from '@/hooks/queries'
function LogoutButton() {
const logoutMutation = useLogout()
return (
<button onClick={() => logoutMutation.mutate()}>
Đăng xuất
</button>
)
}
```
#### Kiểm tra phiên
```tsx
import { usePing } from '@/hooks/queries'
function CheckSession() {
const { data, isLoading } = usePing(token, true)
if (isLoading) return <div>Checking...</div>
return <div>Session: {data?.message}</div>
}
```
#### Thay đổi mật khẩu
```tsx
import { useChangePassword } from '@/hooks/queries'
function ChangePasswordForm() {
const changePasswordMutation = useChangePassword()
const handleSubmit = async () => {
await changePasswordMutation.mutateAsync({
currentPassword: 'old',
newPassword: 'new'
})
}
return <button onClick={handleSubmit}>Thay đổi</button>
}
```
### 2. App Version Queries (Phần mềm/Agent)
#### Lấy danh sách agent
```tsx
import { useGetAgentVersion } from '@/hooks/queries'
function AgentList() {
const { data: agents, isLoading } = useGetAgentVersion()
if (isLoading) return <div>Loading...</div>
return <div>{agents?.length} agents</div>
}
```
#### Lấy danh sách phần mềm
```tsx
import { useGetSoftwareList } from '@/hooks/queries'
function SoftwareList() {
const { data: software, isLoading } = useGetSoftwareList()
return software?.map(item => <div key={item.id}>{item.name}</div>)
}
```
#### Upload file
```tsx
import { useUploadSoftware } from '@/hooks/queries'
function UploadForm() {
const uploadMutation = useUploadSoftware()
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
await uploadMutation.mutateAsync({
formData,
onUploadProgress: (event) => {
const percent = (event.loaded / event.total) * 100
console.log(`Upload: ${percent}%`)
}
})
}
return <input type="file" onChange={(e) => e.target.files && handleUpload(e.target.files[0])} />
}
```
#### Quản lý blacklist
```tsx
import {
useGetBlacklist,
useAddBlacklist,
useDeleteBlacklist
} from '@/hooks/queries'
function BlacklistManager() {
const { data: blacklist } = useGetBlacklist()
const addMutation = useAddBlacklist()
const deleteMutation = useDeleteBlacklist()
const handleAdd = async () => {
await addMutation.mutateAsync({ appId: 1 })
}
const handleDelete = async (appId: number) => {
await deleteMutation.mutateAsync(appId)
}
return (
<>
{blacklist?.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => handleDelete(item.id)}>Delete</button>
</div>
))}
<button onClick={handleAdd}>Add</button>
</>
)
}
```
### 3. Device Communication Queries
#### Lấy danh sách phòng
```tsx
import { useGetRoomList } from '@/hooks/queries'
function RoomSelector() {
const { data: rooms } = useGetRoomList()
return (
<select>
{rooms?.map(room => (
<option key={room.id} value={room.id}>{room.name}</option>
))}
</select>
)
}
```
#### Lấy thiết bị trong phòng
```tsx
import { useGetDeviceFromRoom } from '@/hooks/queries'
function DeviceList({ roomName }: { roomName: string }) {
const { data: devices, isLoading } = useGetDeviceFromRoom(roomName, true)
if (isLoading) return <div>Loading devices...</div>
return devices?.map(device => (
<div key={device.id}>{device.name}</div>
))
}
```
#### Gửi lệnh
```tsx
import { useSendCommand } from '@/hooks/queries'
function CommandForm() {
const sendMutation = useSendCommand()
const handleSend = async () => {
await sendMutation.mutateAsync({
roomName: 'Room A',
data: { command: 'dir' }
})
}
return <button onClick={handleSend}>Gửi lệnh</button>
}
```
#### Cài đặt phần mềm
```tsx
import { useInstallMsi } from '@/hooks/queries'
function InstallSoftware() {
const installMutation = useInstallMsi()
const handleInstall = async () => {
await installMutation.mutateAsync({
roomName: 'Room A',
data: { msiFileId: 1 }
})
}
return <button onClick={handleInstall}>Cài đặt</button>
}
```
### 4. Command Queries
#### Lấy danh sách lệnh
```tsx
import { useGetCommandList } from '@/hooks/queries'
function CommandList() {
const { data: commands } = useGetCommandList()
return commands?.map(cmd => <div key={cmd.id}>{cmd.name}</div>)
}
```
#### Thêm lệnh
```tsx
import { useAddCommand } from '@/hooks/queries'
function AddCommandForm() {
const addMutation = useAddCommand()
const handleAdd = async () => {
await addMutation.mutateAsync({
name: 'My Command',
command: 'echo hello'
})
}
return <button onClick={handleAdd}>Add Command</button>
}
```
## Lợi ích
1. **Automatic Caching** - TanStack Query tự động cache dữ liệu
2. **Background Refetching** - Cập nhật dữ liệu trong background
3. **Stale Time Management** - Kiểm soát thời gian dữ liệu còn "fresh"
4. **Automatic Invalidation** - Tự động update dữ liệu sau mutations
5. **Deduplication** - Gộp các request giống nhau
6. **Error Handling** - Xử lý lỗi tập trung
7. **Loading States** - Tracking loading/pending/error states
## Advanced Usage
### Dependent Queries
```tsx
function DeviceDetails({ deviceId }: { deviceId: number }) {
const { data: device } = useGetDeviceFromRoom(deviceId, true)
// Chỉ fetch khi có device
const { data: status } = useGetClientFolderStatus(
device?.roomName,
!!device
)
return <div>{status?.path}</div>
}
```
### Prefetching
```tsx
import { useQueryClient } from '@tanstack/react-query'
import { useGetSoftwareList } from '@/hooks/queries'
function PrefetchOnHover() {
const queryClient = useQueryClient()
const handleMouseEnter = () => {
queryClient.prefetchQuery({
queryKey: ['app-version', 'software'],
queryFn: () => useGetSoftwareList
})
}
return <div onMouseEnter={handleMouseEnter}>Hover me</div>
}
```
## Migration từ cách cũ
**Trước:**
```tsx
const { data } = useQueryData({
queryKey: ["software-version"],
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
})
```
**Sau:**
```tsx
const { data } = useGetSoftwareList()
```
Đơn giản hơn, type-safe hơn, và dễ bảo trì hơn!

View File

@ -1,174 +0,0 @@
# API Services Documentation
Tất cả logic gọi API đã được tách riêng vào folder `services`. Mỗi service tương ứng với một nhóm API.
## Cấu trúc Services
```
src/services/
├── index.ts # Export tất cả services
├── auth.service.ts # API xác thực
├── app-version.service.ts # API quản lý phần mềm
├── device-comm.service.ts # API thiết bị
├── command.service.ts # API lệnh
└── device.service.ts # Helper functions
```
## Cách Sử Dụng
### 1. Auth Service (Xác thực)
```tsx
import { authService } from '@/services'
// Đăng nhập
const response = await authService.login({
username: 'user',
password: 'pass'
})
// Đăng xuất
await authService.logout()
// Kiểm tra session
const pingResult = await authService.ping(token)
// Thay đổi mật khẩu
await authService.changePassword({
currentPassword: 'old',
newPassword: 'new'
})
// Tạo tài khoản mới (admin)
await authService.createAccount({
userName: 'newuser',
password: 'pass',
name: 'John Doe',
roleId: 1,
accessBuildings: [1, 2, 3]
})
```
### 2. App Version Service (Quản lý phần mềm)
```tsx
import { appVersionService } from '@/services'
// Lấy danh sách agent
const agents = await appVersionService.getAgentVersion()
// Lấy danh sách phần mềm
const software = await appVersionService.getSoftwareList()
// Upload file
const formData = new FormData()
formData.append('file', fileInput.files[0])
await appVersionService.uploadSoftware(formData, (progressEvent) => {
console.log(`Progress: ${progressEvent.loaded}/${progressEvent.total}`)
})
// Lấy blacklist
const blacklist = await appVersionService.getBlacklist()
// Thêm vào blacklist
await appVersionService.addBlacklist({ appId: 1, reason: 'virus' })
// Xóa khỏi blacklist
await appVersionService.deleteBlacklist(1)
```
### 3. Device Comm Service (Thiết bị)
```tsx
import { deviceCommService } from '@/services'
// Lấy tất cả thiết bị
const allDevices = await deviceCommService.getAllDevices()
// Lấy danh sách phòng
const rooms = await deviceCommService.getRoomList()
// Lấy thiết bị trong phòng
const devices = await deviceCommService.getDeviceFromRoom('Room A')
// Gửi lệnh
await deviceCommService.sendCommand('Room A', {
command: 'dir'
})
// Cập nhật agent
await deviceCommService.updateAgent('Room A', { version: '1.0.0' })
// Cài đặt MSI
await deviceCommService.installMsi('Room A', { msiFileId: 1 })
```
### 4. Command Service (Lệnh)
```tsx
import { commandService } from '@/services'
// Lấy danh sách lệnh
const commands = await commandService.getCommandList()
// Thêm lệnh
await commandService.addCommand({ name: 'cmd1', command: 'echo hello' })
// Cập nhật lệnh
await commandService.updateCommand(1, { name: 'cmd1 updated' })
// Xóa lệnh
await commandService.deleteCommand(1)
```
## Sử dụng với React Query/Hooks
### Cách cũ (trực tiếp gọi từ component):
```tsx
const { data } = useQueryData({
queryKey: ["software-version"],
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
})
```
### Cách mới (tách biệt logic):
Có thể tạo custom hooks bao quanh services:
```tsx
// hooks/useGetSoftware.ts
import { useQuery } from '@tanstack/react-query'
import { appVersionService } from '@/services'
export function useGetSoftware() {
return useQuery({
queryKey: ['software-version'],
queryFn: () => appVersionService.getSoftwareList(),
staleTime: 60 * 1000,
})
}
```
Sau đó sử dụng trong component:
```tsx
function AppsComponent() {
const { data, isLoading } = useGetSoftware()
// ...
}
```
## Lợi ích của cách sử dụng mới
1. **Tách biệt logic** - Logic API nằm riêng, dễ bảo trì
2. **Tái sử dụng** - Có thể sử dụng service từ bất kỳ nơi
3. **Dễ test** - Dễ mock services khi viết unit tests
4. **Centralized error handling** - Có thể xử lý lỗi chung
5. **Type safety** - TypeScript types cho tất cả API requests/responses
## Cải tiến trong tương lai
Có thể thêm:
- Global error handling middleware trong axios
- Request/response interceptors cho authentication
- Retry logic cho failed requests
- Request cancellation

View File

@ -1,328 +0,0 @@
# Khác biệt giữa Services và Query Hooks
## Tóm tắt nhanh
| Aspect | Services | Query Hooks |
|--------|----------|-------------|
| **Location** | `src/services/` | `src/hooks/queries/` |
| **Mục đích** | Gọi API trực tiếp | Wrapper TanStack Query |
| **Caching** | ❌ Không | ✅ Có |
| **Background Refetch** | ❌ Không | ✅ Có |
| **Auto Invalidation** | ❌ Không | ✅ Có |
| **Type** | Async functions | React Hooks |
| **Dùng trong** | Non-React code, utilities | React components |
---
## Chi tiết Từng Layer
### 1. Services Layer (`src/services/`)
**Mục đích:** Đơn thuần gọi API và trả về dữ liệu
```typescript
// app-version.service.ts
export async function getSoftwareList(): Promise<Version[]> {
const response = await axios.get<Version[]>(
API_ENDPOINTS.APP_VERSION.GET_SOFTWARE
);
return response.data;
}
```
**Đặc điểm:**
- ✅ Pure async functions
- ✅ Không phụ thuộc vào React
- ✅ Có thể sử dụng ở bất kỳ đâu (utils, servers, non-React code)
- ❌ Không có caching
- ❌ Phải tự quản lý state loading/error
- ❌ Phải tự gọi lại khi dữ liệu thay đổi
**Khi nào dùng:**
```typescript
// Dùng trong utility functions
export async function initializeApp() {
const software = await appVersionService.getSoftwareList();
// ...
}
// Hoặc trong services khác
export async function validateSoftware() {
const list = await appVersionService.getSoftwareList();
// ...
}
```
---
### 2. Query Hooks Layer (`src/hooks/queries/`)
**Mục đích:** Wrapper TanStack Query bên trên services
```typescript
// useAppVersionQueries.ts
export function useGetSoftwareList(enabled = true) {
return useQuery<Version[]>({
queryKey: ["app-version", "software"],
queryFn: () => appVersionService.getSoftwareList(),
enabled,
staleTime: 60 * 1000, // 1 minute
});
}
```
**Đặc điểm:**
- ✅ React hooks
- ✅ Automatic caching
- ✅ Background refetching
- ✅ Automatic invalidation sau mutations
- ✅ Built-in loading/error states
- ✅ Deduplication (gộp requests giống nhau)
- ❌ Chỉ dùng được trong React components
- ❌ Phức tạp hơn services
**Khi nào dùng:**
```typescript
// Dùng trong React components
function SoftwareList() {
const { data: software, isLoading } = useGetSoftwareList()
if (isLoading) return <div>Loading...</div>
return software?.map(item => <div>{item.name}</div>)
}
```
---
## So sánh cụ thể
### Ví dụ 1: Lấy danh sách
**Service - Raw API call:**
```typescript
// services/app-version.service.ts
export async function getSoftwareList(): Promise<Version[]> {
const response = await axios.get(API_ENDPOINTS.APP_VERSION.GET_SOFTWARE);
return response.data;
}
```
**Hook - TanStack Query wrapper:**
```typescript
// hooks/queries/useAppVersionQueries.ts
export function useGetSoftwareList(enabled = true) {
return useQuery({
queryKey: ["app-version", "software"],
queryFn: () => appVersionService.getSoftwareList(),
staleTime: 60 * 1000,
});
}
```
**Sử dụng trong component:**
```typescript
function Component() {
// ❌ KHÔNG nên dùng service trực tiếp
const [data, setData] = useState<Version[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
appVersionService.getSoftwareList().then(d => {
setData(d);
setLoading(false);
});
}, []);
// ✅ NÊN dùng hook thay vì
const { data, isLoading } = useGetSoftwareList();
}
```
---
### Ví dụ 2: Upload file
**Service:**
```typescript
// services/app-version.service.ts
export async function uploadSoftware(
formData: FormData,
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
): Promise<{ message: string }> {
return axios.post(API_ENDPOINTS.APP_VERSION.UPLOAD, formData, {
onUploadProgress,
});
}
```
**Hook:**
```typescript
// hooks/queries/useAppVersionQueries.ts
export function useUploadSoftware() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { formData: FormData; onUploadProgress?: ... }) =>
appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
onSuccess: () => {
// Tự động invalidate software list
queryClient.invalidateQueries({
queryKey: ["app-version", "software"],
});
},
});
}
```
**Sử dụng:**
```typescript
function UploadForm() {
const uploadMutation = useUploadSoftware();
const handleUpload = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
await uploadMutation.mutateAsync({
formData,
onUploadProgress: (e) => console.log(`${e.loaded}/${e.total}`)
});
// ✅ Software list tự động update
};
return (
<button disabled={uploadMutation.isPending}>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</button>
);
}
```
---
## Architecture Flow
```
┌─────────────────────────────────────────┐
│ React Component │
│ (SoftwareList, UploadForm, etc.) │
└──────────────┬──────────────────────────┘
│ uses
┌─────────────────────────────────────────┐
│ Query Hooks (src/hooks/queries/) │
│ - useGetSoftwareList() │
│ - useUploadSoftware() │
│ - useDeleteBlacklist() │
│ - Features: │
│ - Caching │
│ - Auto invalidation │
│ - Loading states │
└──────────────┬──────────────────────────┘
│ wraps
┌─────────────────────────────────────────┐
│ Service Functions (src/services/) │
│ - getSoftwareList() │
│ - uploadSoftware() │
│ - deleteBlacklist() │
│ - Features: │
│ - Pure async functions │
│ - Direct API calls │
└──────────────┬──────────────────────────┘
│ uses
┌─────────────────────────────────────────┐
│ Axios (HTTP Client) │
└──────────────┬──────────────────────────┘
│ requests
┌─────────────────────────────────────────┐
│ Backend API Server │
└─────────────────────────────────────────┘
```
---
## Nguyên tắc sử dụng
### ✅ NÊN dùng Services khi:
- Gọi API từ non-React code (utilities, event handlers, etc.)
- Cần gọi API một lần rồi không cần tracking state
- Không cần caching hay background refetch
- Viết code không phụ thuộc React
```typescript
// ✅ OK - utility function
export async function syncData() {
const software = await appVersionService.getSoftwareList();
const commands = await commandService.getCommandList();
return { software, commands };
}
```
### ✅ NÊN dùng Hooks khi:
- Lấy/update dữ liệu trong React components
- Cần caching và background refetch
- Muốn dữ liệu tự động update
- Cần tracking loading/error states
```typescript
// ✅ OK - React component
function Dashboard() {
const { data: software, isLoading } = useGetSoftwareList();
const uploadMutation = useUploadSoftware();
return <div>{/* ... */}</div>;
}
```
### ❌ KHÔNG nên dùng Services khi:
- Đang trong React component và cần state management
- Cần automatic refetching
- Cần auto-invalidation sau mutations
```typescript
// ❌ WRONG
function Component() {
const [data, setData] = useState([]);
useEffect(() => {
appVersionService.getSoftwareList().then(setData);
}, []);
}
// ✅ RIGHT
function Component() {
const { data } = useGetSoftwareList();
}
```
### ❌ KHÔNG nên dùng Hooks khi:
- Không phải trong React component
- Không có React context
```typescript
// ❌ WRONG - không thể gọi hooks ở đây
export function initApp() {
const { data } = useGetSoftwareList(); // ERROR!
}
// ✅ RIGHT
export async function initApp() {
const data = await appVersionService.getSoftwareList();
}
```
---
## Summary
**Services** = Cơ sở API calls, có thể tái sử dụng ở bất kỳ đâu
**Hooks** = Lớp React trên services, tối ưu cho React components
**Dùng Services** khi bạn cần tính linh hoạt và độc lập với React
**Dùng Hooks** khi bạn muốn TanStack Query quản lý state và caching tự động

View File

@ -1,249 +0,0 @@
# System Admin Priority Logic - Hướng dẫn
## Tổng quan
Đã cập nhật logic để **System Admin** (Priority = 0) trở thành quyền cao nhất trong hệ thống.
### Quy tắc Priority
```
Priority càng thấp = Quyền càng cao
Priority = 0 (System Admin) = Quyền cao nhất
```
## Các thay đổi đã thực hiện
### 1. Constants mới (`src/config/constants.ts`)
```typescript
export const SYSTEM_ADMIN_PRIORITY = 0;
export const RolePriority = {
SYSTEM_ADMIN: 0,
} as const;
```
**Mục đích**: Định nghĩa giá trị priority của System Admin, tránh hardcode số 0 trong code.
---
### 2. Helper Functions (`src/helpers/roleHelpers.ts`)
#### `isSystemAdminPriority(priority: number): boolean`
Kiểm tra xem priority có phải là System Admin không.
```typescript
import { isSystemAdminPriority } from '@/helpers/roleHelpers';
if (isSystemAdminPriority(userPriority)) {
// User là System Admin
}
```
#### `hasHigherOrEqualPriority(priority1, priority2): boolean`
So sánh 2 priority (nhỏ hơn = cao hơn).
```typescript
if (hasHigherOrEqualPriority(userPriority, requiredPriority)) {
// User có đủ quyền
}
```
#### `getPriorityLabel(priority: number): string`
Lấy nhãn mô tả cho priority.
```typescript
getPriorityLabel(0) // "System Admin (Highest)"
getPriorityLabel(5) // "Priority 5"
```
---
### 3. useAuth Hook (`src/hooks/useAuth.tsx`)
Thêm method mới: `isSystemAdmin()`
```typescript
const { isSystemAdmin } = useAuth();
if (isSystemAdmin()) {
// User là System Admin (priority = 0)
console.log('You have highest permission!');
}
```
**Interface cập nhật:**
```typescript
export interface IAuthContext {
// ... các field cũ
isSystemAdmin: () => boolean; // ← Mới
}
```
---
### 4. Sidebar Logic (`src/components/sidebars/app-sidebar.tsx`)
Cập nhật logic kiểm tra admin:
```typescript
// TRƯỚC
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL);
// SAU
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
```
**Lợi ích:**
- System Admin (Priority = 0) thấy tất cả menu items
- Không cần phải có permission `ALLOW_ALL` trong database
---
## Cách sử dụng
### Kiểm tra System Admin trong component
```typescript
import { useAuth } from '@/hooks/useAuth';
function MyComponent() {
const { isSystemAdmin, role } = useAuth();
return (
<div>
{isSystemAdmin() && (
<AdminOnlyFeature />
)}
<p>Role: {role.roleName}</p>
<p>Priority: {role.priority}</p>
</div>
);
}
```
### Kiểm tra priority trong logic nghiệp vụ
```typescript
import { isSystemAdminPriority, hasHigherOrEqualPriority } from '@/helpers/roleHelpers';
function canDeleteUser(currentUserPriority: number, targetUserPriority: number): boolean {
// System Admin có thể xóa bất kỳ ai
if (isSystemAdminPriority(currentUserPriority)) {
return true;
}
// User chỉ có thể xóa user có priority thấp hơn (số lớn hơn)
return hasHigherOrEqualPriority(currentUserPriority, targetUserPriority);
}
```
### Hiển thị label priority
```typescript
import { getPriorityLabel } from '@/helpers/roleHelpers';
<Badge>{getPriorityLabel(role.priority)}</Badge>
// System Admin sẽ hiển thị: "System Admin (Highest)"
```
---
## Luồng kiểm tra quyền
```
User đăng nhập
Priority được lưu vào localStorage
useAuth hook load priority
isSystemAdmin() kiểm tra priority === 0
Sidebar check: ALLOW_ALL || isSystemAdmin()
Hiển thị menu items phù hợp
```
---
## Ví dụ thực tế
### Ví dụ 1: Ẩn/hiện nút Delete dựa trên priority
```typescript
function UserManagement() {
const { role, isSystemAdmin } = useAuth();
const currentUserPriority = role.priority;
function canDelete(targetUserPriority: number): boolean {
// System Admin xóa được tất cả
if (isSystemAdmin()) return true;
// Priority thấp hơn (số nhỏ hơn) mới xóa được
return currentUserPriority < targetUserPriority;
}
return (
<Table>
{users.map(user => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>
{canDelete(user.role.priority) && (
<DeleteButton userId={user.id} />
)}
</TableCell>
</TableRow>
))}
</Table>
);
}
```
### Ví dụ 2: Route protection
```typescript
import { useAuth } from '@/hooks/useAuth';
import { redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_auth/admin-panel')({
beforeLoad: ({ context }) => {
const { isSystemAdmin } = context.auth;
if (!isSystemAdmin()) {
throw redirect({
to: '/unauthorized',
});
}
},
component: AdminPanel,
});
```
---
## Tóm tắt
**Priority = 0** là System Admin (quyền cao nhất)
**Priority thấp hơn** = Quyền cao hơn
✅ Có constants và helpers để tái sử dụng
`isSystemAdmin()` method trong useAuth hook
✅ Sidebar tự động nhận biết System Admin
✅ Không cần hardcode giá trị priority nữa
---
## Files đã thay đổi
1. ✅ `src/config/constants.ts` - Constants mới
2. ✅ `src/helpers/roleHelpers.ts` - Helper functions
3. ✅ `src/hooks/useAuth.tsx` - Thêm isSystemAdmin()
4. ✅ `src/types/auth.ts` - Cập nhật interface
5. ✅ `src/components/sidebars/app-sidebar.tsx` - Logic admin check
---
**Lưu ý quan trọng:**
Backend cũng cần implement logic tương tự để đảm bảo consistency giữa frontend và backend!

View File

@ -1,26 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Debug Permissions</title>
<script>
// Paste this in browser console to debug
const acs = localStorage.getItem('acs');
const role = localStorage.getItem('role');
console.log('Current Role:', role);
console.log('Current ACS (raw):', acs);
console.log('Current ACS (parsed):', acs ? acs.split(',').map(Number) : []);
console.log('VIEW_AGENT permission code:', 171);
console.log('Has VIEW_AGENT?', acs ? acs.split(',').map(Number).includes(171) : false);
</script>
</head>
<body>
<h1>Open Browser Console (F12)</h1>
<p>Or run this in console:</p>
<pre>
const acs = localStorage.getItem('acs');
console.log('Your permissions:', acs);
console.log('As array:', acs ? acs.split(',').map(Number) : []);
console.log('Has VIEW_AGENT (171)?', acs ? acs.split(',').includes('171') : false);
</pre>
</body>
</html>

View File

@ -1,6 +1,6 @@
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 127.0.0.1:5218;
server 172.18.10.8:8080;
}
server {
@ -25,7 +25,7 @@ server {
}
location /api/ {
proxy_pass http://100.66.170.15:8080;
proxy_pass http://backend/;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;

1678
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,14 +30,11 @@
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"shadcn": "^2.9.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

38
src/App.css Normal file
View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

0
src/App.tsx Normal file
View File

View File

@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useForm, formOptions } from "@tanstack/react-form";
import axios from "@/config/axios";
import axios from "axios";
interface AddBlacklistDialogProps {
onAdded?: () => void; // callback để refresh danh sách sau khi thêm

View File

@ -1,37 +0,0 @@
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "./ui/breadcrumb";
import { Link, useMatches } from "@tanstack/react-router";
export default function AppBreadCrumb() {
const matches = useMatches();
const crumbs = matches
.filter((m) => Boolean(m.context.breadcrumbs))
.map((m) => m.context.breadcrumbs)
.filter(Boolean);
const displayCrumbs = crumbs[0] as { path: string; title: string }[];
if (displayCrumbs == null || displayCrumbs.length == 0) return;
return (
<Breadcrumb className="flex-1">
<BreadcrumbList>
{displayCrumbs.slice(0, -1).map((b, index) => (
<>
<BreadcrumbItem key={index} className="md:block">
<Link to={b.path}>{b.title}</Link>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
</>
))}
<BreadcrumbItem className="md:block">
<BreadcrumbPage>{displayCrumbs[displayCrumbs.length - 1].title}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@ -0,0 +1,99 @@
import type React from "react";
import { Link } from "@tanstack/react-router";
import { Building2, Cpu } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
type MenuItem = {
title: string;
to: string;
icon: React.ElementType;
onPointerEnter?: () => void;
};
type AppSidebarProps = {
items: MenuItem[];
};
export function AppSidebar({ items }: AppSidebarProps) {
return (
<Sidebar
collapsible="icon"
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<SidebarHeader className="border-b border-border/40 p-6">
<div className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
<Building2 className="size-5" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
TTMT Computer Management
</span>
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
<Cpu className="size-3" />
v1.0.0
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-4">
<SidebarGroup>
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
Navigation
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="space-y-1">
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
onPointerEnter={item.onPointerEnter}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"group relative overflow-hidden",
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
)}
>
<Link
href={item.to}
to={"."}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
© 2025 NAVIS Centre
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@ -1,93 +0,0 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { LogOut, Settings, User, Key } from "lucide-react";
interface AvatarDropdownProps {
username: string;
role: {
roleName: string;
priority: number;
};
onLogOut: () => void;
onSettings?: () => void;
onProfile?: () => void;
onChangePassword?: () => void;
}
export default function AvatarDropdown({
username,
role,
onLogOut,
onSettings,
onProfile,
onChangePassword,
}: AvatarDropdownProps) {
// Get initials from username
const getInitials = (name: string): string => {
if (!name) return "U";
const parts = name.split(" ");
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
<Avatar className="h-9 w-9 cursor-pointer">
<AvatarImage src="" alt={username} />
<AvatarFallback className="bg-primary text-primary-foreground text-sm font-medium">
{getInitials(username)}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{username}</p>
<p className="text-xs leading-none text-muted-foreground">
{role.roleName || "Người dùng"}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{onProfile && (
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
<User className="mr-2 h-4 w-4" />
<span>Thông tin nhân</span>
</DropdownMenuItem>
)}
{onChangePassword && (
<DropdownMenuItem onClick={onChangePassword} className="cursor-pointer">
<Key className="mr-2 h-4 w-4" />
<span>Đi mật khẩu</span>
</DropdownMenuItem>
)}
{onSettings && (
<DropdownMenuItem onClick={onSettings} className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span>Cài đt</span>
</DropdownMenuItem>
)}
{(onProfile || onChangePassword || onSettings) && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={onLogOut}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Đăng xuất</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,325 +0,0 @@
import { useState, useMemo } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Building2,
Monitor,
ChevronDown,
ChevronRight,
Loader2,
} from "lucide-react";
import type { Room } from "@/types/room";
import type { DeviceHealthCheck } from "@/types/device";
interface DeviceSearchDialogProps {
open: boolean;
onClose: () => void;
rooms: Room[];
onSelect: (deviceIds: string[]) => void | Promise<void>;
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
}
export function DeviceSearchDialog({
open,
onClose,
rooms,
onSelect,
fetchDevices,
}: DeviceSearchDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
const [roomDevices, setRoomDevices] = useState<
Record<string, DeviceHealthCheck[]>
>({});
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = typeof a.name === "string" ? a.name : "";
const nameB = typeof b.name === "string" ? b.name : "";
return nameA.localeCompare(nameB);
});
}, [rooms]);
const filteredRooms = useMemo(() => {
if (!searchQuery) return sortedRooms;
return sortedRooms.filter((room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [sortedRooms, searchQuery]);
const handleRoomClick = async (roomName: string) => {
// Nếu đang mở thì đóng lại
if (expandedRoom === roomName) {
setExpandedRoom(null);
return;
}
// Nếu chưa fetch devices của room này thì gọi API
if (!roomDevices[roomName]) {
setLoadingRoom(roomName);
try {
const devices = await fetchDevices(roomName);
setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
setExpandedRoom(roomName);
} catch (error) {
console.error("Failed to fetch devices:", error);
// Có thể thêm toast notification ở đây
} finally {
setLoadingRoom(null);
}
} else {
// Đã có data rồi thì chỉ toggle
setExpandedRoom(roomName);
}
};
const toggleDevice = (deviceId: string) => {
setSelected((prev) =>
prev.includes(deviceId)
? prev.filter((id) => id !== deviceId)
: [...prev, deviceId]
);
};
const toggleAllInRoom = (roomName: string) => {
const devices = roomDevices[roomName] || [];
const deviceIds = devices.map((d) => d.id);
const allSelected = deviceIds.every((id) => selected.includes(id));
if (allSelected) {
setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
}
};
const handleConfirm = async () => {
try {
await onSelect(selected);
} catch (e) {
console.error("Error on select:", e);
} finally {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
}
};
const handleClose = () => {
setSelected([]);
setExpandedRoom(null);
setRoomDevices({});
setSearchQuery("");
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-6 h-6 text-primary" />
Chọn thiết bị
</DialogTitle>
</DialogHeader>
{/* Search bar */}
<Input
placeholder="Tìm kiếm phòng..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="my-2"
/>
{/* Room list */}
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
<div className="space-y-1">
{filteredRooms.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
Không tìm thấy phòng
</p>
)}
{filteredRooms.map((room) => {
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const allSelected =
devices.length > 0 &&
devices.every((d) => selected.includes(d.id));
const someSelected = devices.some((d) => selected.includes(d.id));
const selectedCount = devices.filter((d) =>
selected.includes(d.id)
).length;
return (
<div
key={room.name}
className="border rounded-lg overflow-hidden"
>
{/* Room header - clickable */}
<div
className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleRoomClick(room.name)}
>
{/* Expand icon or loading */}
{isLoading ? (
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
) : isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
{/* Select all checkbox - chỉ hiện khi đã load devices */}
{devices.length > 0 && (
<Checkbox
checked={allSelected}
onCheckedChange={() => {
toggleAllInRoom(room.name);
}}
onClick={(e) => e.stopPropagation()}
className={
someSelected && !allSelected ? "opacity-50" : ""
}
/>
)}
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
<span className="font-semibold flex-1 text-sm">
{room.name}
</span>
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
{selectedCount > 0 && (
<span className="text-primary font-medium">
{selectedCount}/
</span>
)}
<span>{room.numberOfDevices}</span>
{room.numberOfOfflineDevices > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
{room.numberOfOfflineDevices}
</span>
)}
</div>
</div>
{/* Device table - collapsible */}
{isExpanded && devices.length > 0 && (
<div className="border-t bg-muted/20 overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-muted/50 border-b sticky top-0">
<tr>
<th className="w-8 px-1 py-1"></th>
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
Thiết bị
</th>
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
IP
</th>
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
MAC
</th>
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
Ver
</th>
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
Trạng thái
</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"
>
<td className="px-1 py-1">
<Checkbox
checked={selected.includes(device.id)}
onCheckedChange={() =>
toggleDevice(device.id)
}
/>
</td>
<td className="px-1 py-1">
<div className="flex items-center gap-0.5">
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<span className="font-mono text-xs truncate">
{device.id}
</span>
</div>
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.ipAddress || "-"}
</td>
<td className="px-1 py-1 font-mono text-xs truncate">
{device.networkInfos[0]?.macAddress || "-"}
</td>
<td className="px-1 py-1 text-xs whitespace-nowrap">
{device.version ? `v${device.version}` : "-"}
</td>
<td className="px-1 py-1 text-xs">
{device.isOffline ? (
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
Offline
</span>
) : (
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
Online
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Selected count */}
{selected.length > 0 && (
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
Đã chọn:{" "}
<span className="font-semibold text-foreground">
{selected.length}
</span>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleClose} size="sm">
Hủy
</Button>
<Button
onClick={handleConfirm}
disabled={selected.length === 0}
size="sm"
>
Xác nhận ({selected.length})
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,275 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useGetCommandsByTypes } from "@/hooks/queries/useCommandQueries";
import { useSendCommand } from "@/hooks/queries";
import { CommandType } from "@/types/command-registry";
import {
Power,
PowerOff,
XCircle,
ShieldBan,
ChevronDown,
Loader2,
AlertTriangle
} from "lucide-react";
import { toast } from "sonner";
interface CommandActionButtonsProps {
roomName: string;
selectedDevices?: string[]; // Các thiết bị đã chọn
}
const COMMAND_TYPE_CONFIG = {
[CommandType.RESTART]: {
label: "Khởi động lại",
icon: Power,
color: "text-blue-600",
bgColor: "bg-blue-50 hover:bg-blue-100",
},
[CommandType.SHUTDOWN]: {
label: "Tắt máy",
icon: PowerOff,
color: "text-red-600",
bgColor: "bg-red-50 hover:bg-red-100",
},
[CommandType.TASKKILL]: {
label: "Kết thúc tác vụ",
icon: XCircle,
color: "text-orange-600",
bgColor: "bg-orange-50 hover:bg-orange-100",
},
[CommandType.BLOCK]: {
label: "Chặn",
icon: ShieldBan,
color: "text-purple-600",
bgColor: "bg-purple-50 hover:bg-purple-100",
},
};
export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) {
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
command: any;
commandType: CommandType;
}>({
open: false,
command: null,
commandType: CommandType.RESTART,
});
const [isExecuting, setIsExecuting] = useState(false);
// Query commands for each type
const { data: restartCommands = [] } = useGetCommandsByTypes(CommandType.RESTART.toString());
const { data: shutdownCommands = [] } = useGetCommandsByTypes(CommandType.SHUTDOWN.toString());
const { data: taskkillCommands = [] } = useGetCommandsByTypes(CommandType.TASKKILL.toString());
const { data: blockCommands = [] } = useGetCommandsByTypes(CommandType.BLOCK.toString());
// Send command mutation
const sendCommandMutation = useSendCommand();
const commandsByType = {
[CommandType.RESTART]: restartCommands,
[CommandType.SHUTDOWN]: shutdownCommands,
[CommandType.TASKKILL]: taskkillCommands,
[CommandType.BLOCK]: blockCommands,
};
const handleCommandClick = (command: any, commandType: CommandType) => {
setConfirmDialog({
open: true,
command,
commandType,
});
};
const handleConfirmExecute = async () => {
setIsExecuting(true);
try {
// Chuẩn bị data theo format API (PascalCase)
const apiData = {
Command: confirmDialog.command.commandContent,
QoS: confirmDialog.command.qoS,
IsRetained: confirmDialog.command.isRetained,
};
// Gửi lệnh đến phòng
await sendCommandMutation.mutateAsync({
roomName,
data: apiData as any,
});
toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`);
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART });
// Reload page để tránh freeze
setTimeout(() => {
window.location.reload();
}, 500);
} catch (error) {
console.error("Execute command error:", error);
toast.error("Lỗi khi gửi lệnh!");
setIsExecuting(false);
}
};
const handleCloseDialog = () => {
if (!isExecuting) {
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART });
// Reload để tránh freeze
setTimeout(() => {
window.location.reload();
}, 300);
}
};
const renderCommandButton = (commandType: CommandType) => {
const config = COMMAND_TYPE_CONFIG[commandType];
const commands = commandsByType[commandType];
const Icon = config.icon;
if (!commands || commands.length === 0) {
return (
<Button
key={commandType}
variant="outline"
disabled
size="sm"
className="gap-2"
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
<span className="text-xs text-muted-foreground ml-1">(0)</span>
</Button>
);
}
return (
<DropdownMenu key={commandType}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200`}
>
<Icon className={`h-4 w-4 ${config.color}`} />
{config.label}
<span className="text-xs text-muted-foreground ml-1">({commands.length})</span>
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="bottom"
sideOffset={4}
alignOffset={0}
className="w-64"
avoidCollisions={true}
>
{commands.map((command: any) => (
<DropdownMenuItem
key={command.id}
onClick={() => handleCommandClick(command, commandType)}
className="cursor-pointer"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{command.commandName}</span>
{command.description && (
<span className="text-xs text-muted-foreground">
{command.description}
</span>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<>
<div className="flex flex-wrap gap-2">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}
</div>
{/* Confirm Dialog */}
<Dialog open={confirmDialog.open} onOpenChange={handleCloseDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-600" />
Xác nhận thực thi lệnh
</DialogTitle>
<DialogDescription className="text-left space-y-3">
<p>
Bạn chắc chắn muốn thực thi lệnh <strong>{confirmDialog.command?.commandName}</strong>?
</p>
{confirmDialog.command?.description && (
<p className="text-sm text-muted-foreground">
{confirmDialog.command.description}
</p>
)}
<div className="bg-muted p-3 rounded-md space-y-1">
<p className="text-sm">
<span className="font-medium">Phòng:</span> {roomName}
</p>
<p className="text-sm">
<span className="font-medium">Loại lệnh:</span>{" "}
{COMMAND_TYPE_CONFIG[confirmDialog.commandType]?.label}
</p>
{selectedDevices.length > 0 && (
<p className="text-sm">
<span className="font-medium">Thiết bị đã chọn:</span>{" "}
{selectedDevices.length} thiết bị
</p>
)}
</div>
<p className="text-sm text-orange-600 font-medium">
Lệnh sẽ đưc thực thi ngay lập tức không thể hoàn tác.
</p>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={handleCloseDialog}
disabled={isExecuting}
>
Hủy
</Button>
<Button
onClick={handleConfirmExecute}
disabled={isExecuting}
className="gap-2"
>
{isExecuting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang thực thi...
</>
) : (
"Xác nhận"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,97 +0,0 @@
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface DeleteButtonProps {
onClick: () => void | Promise<void>;
loading?: boolean;
disabled?: boolean;
label?: string;
title?: string;
description?: string;
}
export function DeleteButton({
onClick,
loading = false,
disabled = false,
label = "Xóa khỏi server",
title = "Xóa khỏi server",
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
}: DeleteButtonProps) {
const [open, setOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onClick();
} finally {
setIsConfirming(false);
setOpen(false);
}
};
return (
<>
<Button
variant="destructive"
onClick={() => setOpen(true)}
disabled={loading || disabled}
className="gap-2 px-4"
>
{loading || isConfirming ? (
<span className="animate-spin"></span>
) : (
<Trash2 className="h-4 w-4" />
)}
{loading || isConfirming ? "Đang xóa..." : label}
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-lg">{title}</DialogTitle>
<DialogDescription className="text-base">{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isConfirming}
className="flex-1"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={isConfirming || loading}
className="flex-1 gap-2"
>
{isConfirming ? (
<>
<span className="animate-spin"></span>
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
{label}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
disabled?: boolean;
}
export function ShellCommandForm({
command,
onCommandChange,
disabled,
}: ShellCommandFormProps) {
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full px-0">
<Textarea
className="w-full h-[25vh]"
placeholder="Nhập lệnh..."
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
</form>
);
}

View File

@ -2,19 +2,13 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
}) {
if (!device) {
return (
@ -104,17 +98,6 @@ export function ComputerCard({
{position}
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
isLoading={isCheckingFolder}
/>
</div>
)}
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">

View File

@ -1,17 +1,8 @@
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ComputerCard } from "./computer-card";
import { useMachineNumber } from "../hooks/useMachineNumber";
export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}) {
export function DeviceGrid({ devices }: { devices: any[] }) {
const getMachineNumber = useMachineNumber();
const deviceMap = new Map<number, any>();
@ -23,27 +14,18 @@ export function DeviceGrid({
const totalRows = 5;
const renderRow = (rowIndex: number) => {
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
// Trái: 120
const leftStart = rowIndex * 4 + 1;
// Phải: 2140
const rightStart = 21 + rowIndex * 4;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{/* Bên trái (120) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = leftStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = leftStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
@ -52,21 +34,11 @@ export function DeviceGrid({
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
{/* Bên phải (120) */}
{/* Bên phải (2140) */}
{Array.from({ length: 4 }).map((_, i) => {
const pos = rightStart + (3 - i);
const device = deviceMap.get(pos);
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
const folderStatus = folderStatuses?.get(macAddress);
const pos = rightStart + i;
return (
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
);
})}
</div>
@ -75,18 +47,19 @@ export function DeviceGrid({
return (
<div className="px-0.5 py-8 space-y-6">
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
<Monitor className="h-6 w-6 text-primary" />
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
</div>
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
<DoorOpen className="h-6 w-6 text-muted-foreground" />
<span className="font-semibold text-lg">Cửa Ra Vào</span>
</div>
</div>
<div className="space-y-4">
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
</div>
</div>
);

View File

@ -17,24 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { useMachineNumber } from "../hooks/useMachineNumber";
interface DeviceTableProps {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
}
/**
* Component hiển thị danh sách thiết bị dạng bảng
*/
export function DeviceTable({
devices,
folderStatuses,
isCheckingFolder,
}: DeviceTableProps) {
export function DeviceTable({ devices }: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const columns: ColumnDef<any>[] = [
@ -145,27 +137,6 @@ export function DeviceTable({
);
},
},
{
header: "Thư mục Setup",
cell: ({ row }) => {
const device = row.original;
const isOffline = device.isOffline;
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
const folderStatus = folderStatuses?.get(macAddress);
if (isOffline) {
return <span className="text-muted-foreground text-sm">-</span>;
}
return (
<FolderStatusPopover
deviceId={macAddress}
status={folderStatus}
isLoading={isCheckingFolder}
/>
);
},
},
];
const table = useReactTable({

View File

@ -1,30 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { type ReactNode, useState } from "react";
interface FormDialogProps {
triggerLabel: string;
title: string;
children: (closeDialog: () => void) => ReactNode;
}
export function FormDialog({ triggerLabel, title, children }: FormDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const closeDialog = () => setIsOpen(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{children(closeDialog)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,98 +0,0 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useState, useMemo } from "react";
export interface SelectItem {
label: string;
value: string;
}
interface SelectDialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
onConfirm: (values: string[]) => Promise<void> | void;
}
export function SelectDialog({
open,
onClose,
title,
description,
icon,
items,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())
);
}, [items, search]);
const toggleItem = (value: string) => {
setSelected((prev) =>
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
);
};
const handleConfirm = async () => {
await onConfirm(selected);
setSelected([]);
setSearch("");
onClose();
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{icon}
{title}
</DialogTitle>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</DialogHeader>
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="my-2"
/>
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
{filteredItems.map((item) => (
<div key={item.value} className="flex items-center gap-2">
<Checkbox
checked={selected.includes(item.value)}
onCheckedChange={() => toggleItem(item.value)}
/>
<span>{item.label}</span>
</div>
))}
{filteredItems.length === 0 && (
<p className="text-sm text-muted-foreground text-center">Không kết quả</p>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>
Hủy
</Button>
<Button onClick={handleConfirm} disabled={selected.length === 0}>
Xác nhận
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,145 +0,0 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
import type { ClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
interface FolderStatusPopoverProps {
deviceId: string;
status?: ClientFolderStatus;
isLoading?: boolean;
}
export function FolderStatusPopover({
deviceId,
status,
isLoading,
}: FolderStatusPopoverProps) {
const hasMissing = status && status.missingFiles.length > 0;
const hasExtra = status && status.extraFiles.length > 0;
const hasIssues = hasMissing || hasExtra;
// Xác định màu sắc và icon dựa trên trạng thái
let statusColor = "text-green-500";
let statusIcon = (
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
);
if (isLoading) {
statusColor = "text-blue-500";
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
} else if (hasMissing && hasExtra) {
// Vừa thiếu vừa thừa -> Đỏ + Alert
statusColor = "text-red-600";
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasMissing) {
// Chỉ thiếu -> Đỏ
statusColor = "text-red-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
} else if (hasExtra) {
// Chỉ thừa -> Cam
statusColor = "text-orange-500";
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
}
return (
<Popover>
<PopoverTrigger asChild>
<button className="p-2 hover:bg-muted rounded-md transition-colors">
{statusIcon}
</button>
</PopoverTrigger>
<PopoverContent className="w-96 p-4" side="right">
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
{hasIssues && (
<Badge variant="destructive" className="text-xs">
{hasMissing && hasExtra
? "Không đồng bộ"
: hasMissing
? "Thiếu file"
: "Thừa file"}
</Badge>
)}
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Đang kiểm tra...
</div>
) : !status ? (
<div className="text-sm text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="space-y-3">
{/* File thiếu */}
{hasMissing && (
<div className="border-l-4 border-red-500 pl-3">
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thiếu ({status.missingFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
<div className="space-y-2">
{status.missingFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-red-200"
>
<div className="font-mono font-semibold text-red-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* File thừa */}
{hasExtra && (
<div className="border-l-4 border-orange-500 pl-3">
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
File thừa ({status.extraFiles.length})
</h4>
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
<div className="space-y-2">
{status.extraFiles.map((file, idx) => (
<div
key={idx}
className="text-xs bg-white rounded p-2 border border-orange-200"
>
<div className="font-mono font-semibold text-orange-700">
{file.fileName}
</div>
<div className="text-xs text-muted-foreground mt-1">
{file.folderPath}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
{/* Trạng thái OK */}
{!hasIssues && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
<span className="font-medium">Thư mục đt yêu cầu</span>
</div>
)}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -1,67 +0,0 @@
import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form";
import { type BlacklistFormData } from "@/types/black-list";
import { toast } from "sonner";
interface BlacklistFormProps {
onSubmit: (data: BlacklistFormData) => Promise<void>;
closeDialog: () => void;
initialData?: Partial<BlacklistFormData>;
}
export function BlacklistForm({
onSubmit,
closeDialog,
initialData,
}: BlacklistFormProps) {
return (
<FormBuilder<BlacklistFormData>
defaultValues={{
appName: initialData?.appName || "",
processName: initialData?.processName || "",
}}
onSubmit={async (values: BlacklistFormData) => {
if (!values.appName.trim()) {
toast.error("Vui lòng nhập tên ứng dụng");
return;
}
if (!values.processName.trim()) {
toast.error("Vui lòng nhập tên tiến trình");
return;
}
try {
await onSubmit(values);
toast.success("Thêm phần mềm bị chặn thành công!");
closeDialog();
} catch (error) {
console.error("Error:", error);
toast.error("Có lỗi xảy ra!");
}
}}
submitLabel="Thêm"
cancelLabel="Hủy"
onCancel={closeDialog}
showCancel={true}
>
{(form: any) => (
<>
<FormField<BlacklistFormData, "appName">
form={form}
name="appName"
label="Tên ứng dụng"
placeholder="VD: Google Chrome"
required
/>
<FormField<BlacklistFormData, "processName">
form={form}
name="processName"
label="Tên tiến trình"
placeholder="VD: chrome.exe"
required
/>
</>
)}
</FormBuilder>
);
}

View File

@ -1,164 +0,0 @@
"use client";
import { useForm } from "@tanstack/react-form";
import { z } from "zod";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
export interface ShellCommandData {
command: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
interface ShellCommandFormProps {
command: string;
onCommandChange: (value: string) => void;
qos?: 0 | 1 | 2;
onQoSChange?: (value: 0 | 1 | 2) => void;
isRetained?: boolean;
onIsRetainedChange?: (value: boolean) => void;
disabled?: boolean;
}
const QoSDescriptions = {
0: {
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
},
1: {
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
},
2: {
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
},
};
export function ShellCommandForm({
command,
onCommandChange,
qos = 0,
onQoSChange,
isRetained = false,
onIsRetainedChange,
disabled,
}: ShellCommandFormProps) {
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
const form = useForm({
defaultValues: { command },
onSubmit: () => {},
});
const handleQoSChange = (value: string) => {
const newQoS = Number(value) as 0 | 1 | 2;
setSelectedQoS(newQoS);
onQoSChange?.(newQoS);
};
const handleRetainedChange = (checked: boolean) => {
onIsRetainedChange?.(checked);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="space-y-5"
>
{/* Command Input */}
<form.Field
name="command"
validators={{
onChange: ({ value }: { value: string }) => {
const schema = z
.string()
.min(1, "Nhập command để thực thi")
.max(500, "Command quá dài");
const result = schema.safeParse(value);
if (!result.success) {
return result.error.issues.map((i) => i.message);
}
return [];
},
}}
children={(field) => (
<div className="w-full space-y-2">
<Label>Nội Dung Lệnh *</Label>
<Textarea
className="w-full h-[20vh] font-mono"
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
value={field.state.value}
onChange={(e) => {
field.handleChange(e.target.value);
onCommandChange(e.target.value);
}}
disabled={disabled}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
/>
{/* QoS Selection */}
<div className="space-y-2">
<Label>QoS (Quality of Service) *</Label>
<select
value={selectedQoS}
onChange={(e) => handleQoSChange(e.target.value)}
disabled={disabled}
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{/* QoS Description */}
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm text-blue-800 mt-1">
<div className="font-semibold">
{QoSDescriptions[selectedQoS].name}
</div>
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
</AlertDescription>
</Alert>
</div>
{/* Retained Checkbox */}
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
id="retained"
checked={isRetained}
onCheckedChange={handleRetainedChange}
disabled={disabled}
/>
<div className="flex-1">
<Label htmlFor="retained" className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi kết nối. Hữu ích
cho các lệnh cấu hình cần duy trì trạng thái.
</p>
</div>
</div>
</form>
);
}

View File

@ -1,416 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Info } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { CommandType } from "@/types/command-registry";
interface CommandRegistryFormProps {
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
closeDialog?: () => void;
initialData?: Partial<CommandRegistryFormData>;
title?: string;
}
export interface CommandRegistryFormData {
commandName: string;
commandType: CommandType;
description?: string;
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
}
// Zod validation schema
const commandRegistrySchema = z.object({
commandName: z
.string()
.min(1, "Tên lệnh không được để trống")
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
.max(100, "Tên lệnh tối đa 100 ký tự")
.trim(),
commandType: z.nativeEnum(CommandType, {
errorMap: () => ({ message: "Loại lệnh không hợp lệ" }),
}),
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
commandContent: z
.string()
.min(1, "Nội dung lệnh không được để trống")
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
});
const QoSLevels = [
{
level: 0,
name: "At Most Once (Fire and Forget)",
description:
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
},
{
level: 1,
name: "At Least Once",
description:
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
},
{
level: 2,
name: "Exactly Once",
description:
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
},
];
export function CommandRegistryForm({
onSubmit,
closeDialog,
initialData,
title = "Đăng ký Lệnh Mới",
}: CommandRegistryFormProps) {
const [selectedQoS, setSelectedQoS] = useState<number>(
initialData?.qos ?? 0
);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm({
defaultValues: {
commandName: initialData?.commandName || "",
commandType: initialData?.commandType || CommandType.RESTART,
description: initialData?.description || "",
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
},
onSubmit: async ({ value }) => {
try {
// Validate using Zod
const validatedData = commandRegistrySchema.parse(value);
setIsSubmitting(true);
await onSubmit(validatedData as CommandRegistryFormData);
toast.success("Lưu lệnh thành công!");
if (closeDialog) {
closeDialog();
}
} catch (error: any) {
if (error.errors?.length > 0) {
toast.error(error.errors[0].message);
} else {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra khi lưu lệnh!");
}
} finally {
setIsSubmitting(false);
}
},
});
return (
<div className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>
Tạo cấu hình lệnh MQTT mới đ điều khiển thiết bị
</CardDescription>
</CardHeader>
<CardContent>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{/* Tên lệnh */}
<form.Field name="commandName">
{(field: any) => (
<div className="space-y-2">
<Label>
Tên Lệnh <span className="text-red-500">*</span>
</Label>
<Input
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Tên đnh danh duy nhất cho lệnh này
</p>
</div>
)}
</form.Field>
{/* Loại lệnh */}
<form.Field name="commandType">
{(field: any) => (
<div className="space-y-2">
<Label>
Loại Lệnh <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value={CommandType.RESTART}>RESTART - Khởi đng lại</option>
<option value={CommandType.SHUTDOWN}>SHUTDOWN - Tắt máy</option>
<option value={CommandType.TASKKILL}>TASKKILL - Kết thúc tác vụ</option>
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Phân loại lệnh đ dễ dàng quản tổ chức
</p>
</div>
)}
</form.Field>
{/* Mô tả */}
<form.Field name="description">
{(field: any) => (
<div className="space-y-2">
<Label> Tả (Tùy chọn)</Label>
<Textarea
placeholder="Nhập mô tả chi tiết về lệnh này..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={3}
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
tả chi tiết về chức năng cách sử dụng lệnh
</p>
</div>
)}
</form.Field>
{/* Nội dung lệnh */}
<form.Field name="commandContent">
{(field: any) => (
<div className="space-y-2">
<Label>
Nội Dung Lệnh <span className="text-red-500">*</span>
</Label>
<Textarea
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
disabled={isSubmitting}
rows={5}
className="font-mono text-sm"
/>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
<p className="text-sm text-muted-foreground">
Nội dung lệnh sẽ đưc gửi tới thiết bị (PowerShell, CMD, bash...)
</p>
</div>
)}
</form.Field>
{/* QoS Level */}
<form.Field name="qos">
{(field: any) => (
<div className="space-y-2">
<Label>
QoS (Quality of Service) <span className="text-red-500">*</span>
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => {
const value = Number(e.target.value) as 0 | 1 | 2;
field.handleChange(value);
setSelectedQoS(value);
}}
onBlur={field.handleBlur}
disabled={isSubmitting}
>
<option value="0">QoS 0 - At Most Once (Tốc đ cao)</option>
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
<option value="2">QoS 2 - Exactly Once (Đ tin cậy cao)</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
{String(field.state.meta.errors[0])}
</p>
)}
</div>
)}
</form.Field>
{/* Chú thích QoS */}
{selectedQoS !== null && (
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
<Info className="h-4 w-4 text-blue-600" />
<AlertDescription className="text-sm space-y-3 mt-2">
<div>
<div className="font-semibold text-blue-900">
{QoSLevels[selectedQoS].name}
</div>
<div className="text-blue-800 mt-1">
{QoSLevels[selectedQoS].description}
</div>
<div className="text-blue-700 mt-2">
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
{QoSLevels[selectedQoS].useCase}
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* Bảng so sánh QoS */}
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-base">
Bảng So Sánh Các Mức QoS
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2 font-semibold">
Tiêu Chí
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 0
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 1
</th>
<th className="text-center py-2 px-2 font-semibold">
QoS 2
</th>
</tr>
</thead>
<tbody>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đm bảo gửi</td>
<td className="text-center">Không</td>
<td className="text-center"></td>
<td className="text-center">Chính xác</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tốc đ</td>
<td className="text-center">Nhanh nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Chậm nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Tài nguyên</td>
<td className="text-center">Ít nhất</td>
<td className="text-center">Trung bình</td>
<td className="text-center">Nhiều nhất</td>
</tr>
<tr className="border-b bg-white">
<td className="py-2 px-2">Đ tin cậy</td>
<td className="text-center">Thấp</td>
<td className="text-center">Cao</td>
<td className="text-center">Cao nhất</td>
</tr>
<tr className="bg-white">
<td className="py-2 px-2">Số lần nhận tối đa</td>
<td className="text-center">1 (hoặc 0)</td>
<td className="text-center"> 1</td>
<td className="text-center">1</td>
</tr>
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* IsRetained Checkbox */}
<form.Field name="isRetained">
{(field: any) => (
<div className="flex items-center gap-3 rounded-lg border p-4">
<Checkbox
checked={field.state.value}
onCheckedChange={field.handleChange}
disabled={isSubmitting}
/>
<div className="flex-1">
<Label className="text-base cursor-pointer">
Lưu giữ lệnh (Retained)
</Label>
<p className="text-sm text-muted-foreground mt-1">
Broker MQTT sẽ lưu lệnh này gửi cho client mới khi
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
thái.
</p>
</div>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button
type="submit"
disabled={isSubmitting}
className="flex-1"
>
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
</Button>
{closeDialog && (
<Button
type="button"
variant="outline"
disabled={isSubmitting}
className="flex-1"
onClick={closeDialog}
>
Hủy
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,161 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { type ReactNode } from "react";
interface FormBuilderProps<T extends Record<string, any>> {
defaultValues: T;
onSubmit: (values: T) => Promise<void> | void;
submitLabel?: string;
cancelLabel?: string;
onCancel?: () => void;
showCancel?: boolean;
children: (form: any) => ReactNode;
}
export function FormBuilder<T extends Record<string, any>>({
defaultValues,
onSubmit,
submitLabel = "Submit",
cancelLabel = "Hủy",
onCancel,
showCancel = false,
children,
}: FormBuilderProps<T>) {
const form = useForm({
defaultValues,
onSubmit: async ({ value }) => {
try {
await onSubmit(value as T);
} catch (error) {
console.error("Submit error:", error);
toast.error("Có lỗi xảy ra!");
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
{children(form)}
<div className="flex justify-end gap-2">
{showCancel && onCancel && (
<Button type="button" variant="outline" onClick={onCancel}>
{cancelLabel}
</Button>
)}
<Button type="submit">{submitLabel}</Button>
</div>
</form>
);
}
interface FormFieldProps<T, K extends keyof T> {
form: any;
name: K;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
}
export function FormField<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
type = "text",
placeholder,
required,
}: FormFieldProps<T, K>) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<Input
type={type}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
placeholder,
required,
}: Omit<FormFieldProps<T, K>, "type">) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<textarea
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder={placeholder}
/>
</div>
)}
</form.Field>
);
}
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
form,
name,
label,
options,
required,
}: {
form: any;
name: K;
label: string;
options: { value: string; label: string }[];
required?: boolean;
}) {
return (
<form.Field name={name as string}>
{(field: any) => (
<div>
<Label>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</form.Field>
);
}

View File

@ -1,112 +0,0 @@
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { LoginResquest } from "@/types/auth";
import { useMutation } from "@tanstack/react-query";
import { login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/hooks/useAuth";
import { LoaderCircle } from "lucide-react";
export function LoginForm({ className }: React.ComponentProps<"form">) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [formData, setFormData] = useState<LoginResquest>({
username: "",
password: "",
});
const auth = useAuth();
const router = useRouter();
const navigate = useNavigate();
const search = Route.useSearch() as { redirect?: string };
const mutation = useMutation({
mutationFn: login,
async onSuccess(data) {
localStorage.setItem("accesscontrol.auth.user", data.username!);
localStorage.setItem("token", data.token!);
localStorage.setItem("name", data.name!);
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role.roleName ?? "");
localStorage.setItem("priority", (data.role.priority ?? 0).toString());
auth.setAuthenticated(true);
auth.login(data.username!);
await router.invalidate();
await navigate({ to: search.redirect || "/dashboard" });
},
onError(error) {
setErrorMessage(error.message || "Login failed");
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
mutation.mutate(formData);
};
return (
<div className={cn("flex flex-col gap-6", className)}>
<Card>
<CardHeader className="text-center flex flex-col items-center">
<CardTitle className="text-xl flex items-center gap-3">
<img src="/soict_logo.png" alt="logo" className="size-20" />
<p> Computer Management</p>
</CardTitle>
<CardDescription>Hệ thống quản phòng máy thực hành</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
<div className="grid gap-6">
<div className="grid gap-3">
<Label htmlFor="email">Tên đăng nhập</Label>
<Input
id="email"
type="text"
autoFocus
required
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
/>
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Mật khẩu</Label>
</div>
<Input
id="password"
type="password"
required
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
</div>
{errorMessage && (
<div className="text-destructive text-sm font-medium">{errorMessage}</div>
)}
{mutation.isPending ? (
<Button className="w-full" disabled>
<LoaderCircle className="w-4 h-4 mr-1 animate-spin" />
Đang đăng nhập
</Button>
) : (
<Button type="submit" className="w-full">
Đăng nhập
</Button>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,139 +0,0 @@
import { useForm } from "@tanstack/react-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import type { AxiosProgressEvent } from "axios";
import { useState } from "react";
import { toast } from "sonner";
interface UploadVersionFormProps {
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
closeDialog: () => void;
}
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
const isFileValid = (file: File) => {
const fileName = file.name.toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
};
const form = useForm({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
// Validate file types
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
if (invalidFiles.length > 0) {
toast.error(
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
);
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
</p>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<div className="flex justify-end gap-2">
{!isDone ? (
<>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={closeDialog}>
Hoàn tất
</Button>
)}
</div>
</form>
);
}

View File

@ -1,140 +0,0 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
import { useState } from "react";
interface DeleteMenuProps {
onDeleteFromServer: () => void;
onDeleteFromRequired: () => void;
loading?: boolean;
label?: string;
serverLabel?: string;
requiredLabel?: string;
}
export function DeleteMenu({
onDeleteFromServer,
onDeleteFromRequired,
loading,
label = "Xóa",
serverLabel = "Xóa khỏi server",
requiredLabel = "Xóa khỏi danh sách yêu cầu",
}: DeleteMenuProps) {
const [open, setOpen] = useState(false);
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const handleDeleteFromServer = async () => {
try {
await onDeleteFromServer();
} finally {
setOpen(false);
setShowConfirmDelete(false);
}
};
const handleDeleteFromRequired = async () => {
try {
await onDeleteFromRequired();
} finally {
setOpen(false);
}
};
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="destructive"
disabled={loading}
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
>
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang xóa..." : label}
</span>
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleDeleteFromRequired}
disabled={loading}
className="focus:bg-orange-50 focus:text-orange-900"
>
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
<span>{requiredLabel}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setShowConfirmDelete(true)}
disabled={loading}
className="focus:bg-red-50 focus:text-red-900"
>
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
<span>{serverLabel}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation Dialog for Delete from Server */}
{showConfirmDelete && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
<div className="flex items-center gap-3 mb-4">
<AlertTriangle className="h-6 w-6 text-red-600" />
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
</div>
<p className="text-muted-foreground mb-6">
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành đng này <strong>không thể hoàn tác</strong> sẽ xóa vĩnh viễn tất cả các tệp liên quan.
</p>
<p className="text-sm text-red-600 mb-6 font-medium">
Vui lòng chắc chắn trước khi tiếp tục.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setShowConfirmDelete(false)}
disabled={loading}
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleDeleteFromServer}
disabled={loading}
className="gap-2"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang xóa...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Xóa khỏi server
</>
)}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -1,16 +0,0 @@
import { AxiosError } from "axios";
export function ErrorFetchingPage({ error }: { error: Error | AxiosError }) {
return (
<div className="p-4 text-destructive">
<h2 className="text-xl font-semibold mb-2">Lỗi</h2>
<p>
{"isAxiosError" in error &&
error.response?.data &&
(error.response.data as { message?: string }).message
? (error.response.data as { message?: string }).message
: "Lỗi trong quá trình render page"}
</p>
</div>
);
}

View File

@ -1,24 +0,0 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
export default function ErrorRoute({ error }: { error: string }) {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<div className="bg-destructive/10 rounded-full p-6 mb-6">
<AlertTriangle className="h-12 w-12 text-destructive" />
</div>
<h1 className="text-4xl font-bold mb-4">Lỗi</h1>
<p className="text-muted-foreground mb-8 max-w-md">
Đã xảy ra lỗi: <strong>{error}</strong>
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button asChild variant="outline">
<Link to="/dashboard">Về trang chủ</Link>
</Button>
</div>
</div>
);
}

View File

@ -1,35 +0,0 @@
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="space-y-6 max-w-md mx-auto">
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="space-y-3">
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter">404</h1>
<h2 className="text-2xl md:text-3xl font-semibold">Không tìm thấy</h2>
<p className="text-muted-foreground">
Trang bạn yêu cầu truy cập không tồn tại hoặc đã bị xoá.
</p>
</div>
<div className="pt-6">
<Button asChild size="lg" className="gap-2">
<Link to="/dashboard" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Trở về trang chủ
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,43 +0,0 @@
import { Button } from "@/components/ui/button";
import { Route } from "@/routes/_auth";
import { useRouter } from "@tanstack/react-router";
import { ArrowLeft, Search } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
export default function SessionTimeOutErrorPage() {
const router = useRouter();
const navigate = Route.useNavigate();
const auth = useAuth();
const handleLogout = () => {
auth.logout();
router.invalidate().finally(() => {
navigate({ to: "/login" });
});
};
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
<div className="space-y-6 max-w-md mx-auto">
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="space-y-3">
<h2 className="text-2xl md:text-3xl font-semibold">Phiên hết hạn</h2>
<p className="text-muted-foreground">Bạn cần đăng nhập lại</p>
</div>
<div className="pt-6">
<Button asChild size="lg" className="gap-2" onClick={handleLogout}>
<div className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Trở về trang đăng nhập
</div>
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,74 +0,0 @@
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { RowsPerPage } from "./rows-per-page";
interface PaginationProps {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
pageSizeOptions?: number[];
}
export function CustomPagination({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange,
onPageSizeChange,
pageSizeOptions
}: PaginationProps) {
const startItem = Math.max(1, (currentPage - 1) * itemsPerPage + 1);
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className="flex flex-col sm:flex-row items-center gap-4">
{onPageSizeChange && (
<RowsPerPage
pageSize={itemsPerPage}
onPageSizeChange={onPageSizeChange}
options={pageSizeOptions}
/>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(1)}
disabled={currentPage === 1}>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="mx-2 text-sm">
{startItem}-{endItem} của {totalItems}
</span>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => onPageChange(totalPages)}
disabled={currentPage === totalPages}>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -1,45 +0,0 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
interface RowsPerPageProps {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
options?: number[];
}
export function RowsPerPage({
pageSize,
onPageSizeChange,
options = [5, 10, 15, 20]
}: RowsPerPageProps) {
return (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Hiển thị</span>
<Select
value={pageSize?.toString()}
onValueChange={(value) => onPageSizeChange(Number(value))}>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={pageSize?.toString()} />
</SelectTrigger>
<SelectContent>
{!options.includes(pageSize) && (
<SelectItem value={pageSize?.toString()} disabled>
{pageSize}
</SelectItem>
)}
{options.map((option) => (
<SelectItem key={option} value={option?.toString()}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">mục</span>
</div>
);
}

View File

@ -0,0 +1,151 @@
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { Play, PlayCircle } from "lucide-react"
import { useState } from "react"
interface PresetCommand {
id: string
label: string
command: string
description?: string
}
interface PresetCommandsProps {
onSelectCommand: (command: string) => void
onExecuteMultiple?: (commands: string[]) => void
disabled?: boolean
}
// Danh sách các command có sẵn
const PRESET_COMMANDS: PresetCommand[] = [
{
id: "check-disk",
label: "Kiểm tra dung lượng ổ đĩa",
command: "df -h",
description: "Hiển thị thông tin dung lượng các ổ đĩa",
},
{
id: "check-memory",
label: "Kiểm tra RAM",
command: "free -h",
description: "Hiển thị thông tin bộ nhớ RAM",
},
{
id: "check-cpu",
label: "Kiểm tra CPU",
command: "top -bn1 | head -20",
description: "Hiển thị thông tin CPU và tiến trình",
},
{
id: "list-processes",
label: "Danh sách tiến trình",
command: "ps aux",
description: "Liệt kê tất cả tiến trình đang chạy",
},
{
id: "network-info",
label: "Thông tin mạng",
command: "ifconfig",
description: "Hiển thị cấu hình mạng",
},
{
id: "system-info",
label: "Thông tin hệ thống",
command: "uname -a",
description: "Hiển thị thông tin hệ điều hành",
},
{
id: "uptime",
label: "Thời gian hoạt động",
command: "uptime",
description: "Hiển thị thời gian hệ thống đã chạy",
},
{
id: "reboot",
label: "Khởi động lại",
command: "reboot",
description: "Khởi động lại thiết bị",
},
]
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
const handleToggleCommand = (commandId: string) => {
setSelectedCommands((prev) => {
const newSet = new Set(prev)
if (newSet.has(commandId)) {
newSet.delete(commandId)
} else {
newSet.add(commandId)
}
return newSet
})
}
const handleExecuteSelected = () => {
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
if (commands.length > 0 && onExecuteMultiple) {
onExecuteMultiple(commands)
setSelectedCommands(new Set()) // Clear selection after execution
}
}
const handleSelectAll = () => {
if (selectedCommands.size === PRESET_COMMANDS.length) {
setSelectedCommands(new Set())
} else {
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
</Button>
{selectedCommands.size > 0 && (
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
<PlayCircle className="h-4 w-4 mr-2" />
Thực thi {selectedCommands.size} lệnh
</Button>
)}
</div>
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
<div className="space-y-2">
{PRESET_COMMANDS.map((preset) => (
<div
key={preset.id}
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
>
<Checkbox
checked={selectedCommands.has(preset.id)}
onCheckedChange={() => handleToggleCommand(preset.id)}
disabled={disabled}
className="mt-1"
/>
<div className="flex-1 space-y-1">
<div className="font-medium text-sm">{preset.label}</div>
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onSelectCommand(preset.command)}
disabled={disabled}
className="shrink-0"
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</ScrollArea>
</div>
)
}

View File

@ -7,18 +7,12 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
import { useState } from "react";
interface RequestUpdateMenuProps {
onUpdateDevice: () => void;
onUpdateRoom: () => void;
onUpdateAll: () => void;
loading?: boolean;
label?: string;
deviceLabel?: string;
roomLabel?: string;
allLabel?: string;
icon?: React.ReactNode;
}
export function RequestUpdateMenu({
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
onUpdateRoom,
onUpdateAll,
loading,
label = "Cập nhật",
deviceLabel = "Thiết bị cụ thể",
roomLabel = "Theo phòng",
allLabel = "Tất cả thiết bị",
icon,
}: RequestUpdateMenuProps) {
const [open, setOpen] = useState(false);
const handleUpdateDevice = async () => {
try {
await onUpdateDevice();
} finally {
setOpen(false);
}
};
const handleUpdateRoom = async () => {
try {
await onUpdateRoom();
} finally {
setOpen(false);
}
};
const handleUpdateAll = async () => {
try {
await onUpdateAll();
} finally {
setOpen(false);
}
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
<div className="flex items-center gap-2">
{loading ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
) : icon ? (
<div className="h-4 w-4 text-gray-600">{icon}</div>
) : (
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
)}
<span className="text-sm font-semibold">
{loading ? "Đang gửi..." : label}
{loading ? "Đang gửi..." : "Cập nhật"}
</span>
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div>
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{deviceLabel}</span>
<span>Cập nhật thiết bị cụ thể</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{roomLabel}</span>
<span>Cập nhật theo phòng</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
<RefreshCw className="h-4 w-4 mr-2" />
<span>{allLabel}</span>
<span>Cập nhật tất cả thiết bị</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useMemo } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Check, Search } from "lucide-react"
import { Input } from "@/components/ui/input"
interface SelectDialogProps {
open: boolean
onClose: () => void
items: string[] // danh sách chung: có thể là devices hoặc rooms
title?: string // tiêu đề động
description?: string // mô tả ngắn
icon?: React.ReactNode // icon thay đổi tùy loại
onConfirm: (selected: string[]) => void
}
export function SelectDialog({
open,
onClose,
items,
title = "Chọn mục",
description = "Bạn có thể chọn nhiều mục để thao tác",
icon,
onConfirm,
}: SelectDialogProps) {
const [selectedItems, setSelectedItems] = useState<string[]>([])
const [search, setSearch] = useState("")
useEffect(() => {
if (!open) {
setSelectedItems([])
setSearch("")
}
}, [open])
const toggleItem = (item: string) => {
setSelectedItems((prev) =>
prev.includes(item)
? prev.filter((i) => i !== item)
: [...prev, item]
)
}
// Lọc danh sách theo từ khóa
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.toLowerCase().includes(search.toLowerCase())
)
}, [items, search])
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="text-center pb-4">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
{icon ?? <Search className="w-6 h-6 text-primary" />}
</div>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">{description}</p>
</DialogHeader>
{/* 🔍 Thanh tìm kiếm */}
<div className="relative mb-3">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Tìm kiếm..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{/* Danh sách các item */}
<div className="py-3 space-y-3 max-h-64 overflow-y-auto">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item}
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
onClick={() => toggleItem(item)}
>
<div className="flex items-center gap-3">
<Checkbox
checked={selectedItems.includes(item)}
onCheckedChange={() => toggleItem(item)}
/>
<Label className="font-medium cursor-pointer hover:text-primary">
{item}
</Label>
</div>
{selectedItems.includes(item) && (
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
)}
</div>
))
) : (
<p className="text-center text-sm text-muted-foreground py-4">
Không tìm thấy kết quả
</p>
)}
</div>
<DialogFooter className="gap-2 pt-4">
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
Hủy
</Button>
<Button
onClick={() => {
if (selectedItems.length > 0) {
onConfirm(selectedItems)
onClose()
}
}}
disabled={selectedItems.length === 0}
className="flex-1 sm:flex-none"
>
<Check className="w-4 h-4 mr-2" />
Xác nhận ({selectedItems.length})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -1,149 +0,0 @@
import type React from "react";
import { Link } from "@tanstack/react-router";
import { Building2, Cpu } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { appSidebarSection } from "@/types/app-sidebar";
import { PermissionEnum } from "@/types/permission";
import { useAuth } from "@/hooks/useAuth";
import { useMemo } from "react";
type SidebarItem = {
title: string;
url: string;
code?: number;
icon: React.ElementType;
permissions?: PermissionEnum[];
};
type SidebarSection = {
title: string;
items: SidebarItem[];
};
export function AppSidebar() {
const { hasPermission, acs, isSystemAdmin } = useAuth();
// Check if user is admin (has ALLOW_ALL permission OR is System Admin with priority 0)
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
// Check if user has any of the required permissions
const checkPermissions = (permissions?: PermissionEnum[]) => {
// No permissions defined = show to everyone
if (!permissions || permissions.length === 0) return true;
// Item marked as ALLOW_ALL = show to everyone
if (permissions.includes(PermissionEnum.ALLOW_ALL)) return true;
// Admin users OR System Admin (priority=0) see everything
if (isAdmin) return true;
// Check if user has any of the required permissions
return permissions.some((permission) => hasPermission(permission));
};
// Filter sidebar sections and items based on permissions
const filteredNavMain = useMemo(() => {
return appSidebarSection.navMain
.map((section) => ({
...section,
items: section.items.filter((item) => checkPermissions(item.permissions)),
}))
.filter((section) => section.items.length > 0) as SidebarSection[];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [acs]);
return (
<TooltipProvider delayDuration={300}>
<Sidebar
collapsible="icon"
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<SidebarHeader className="border-b border-border/40 p-6">
<div className="flex items-center gap-3">
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
<Building2 className="size-5" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
TTMT Computer Management
</span>
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
<Cpu className="size-3" />
v1.0.0
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-4">
{filteredNavMain.map((section) => (
<SidebarGroup key={section.title}>
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
{section.title}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="space-y-1">
{section.items.map((item) => (
<SidebarMenuItem key={item.title}>
<Tooltip>
<TooltipTrigger asChild>
<SidebarMenuButton
asChild
tooltip={item.title}
className={cn(
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
"transition-all duration-200 ease-in-out",
"group relative overflow-hidden",
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
)}
>
<Link
href={item.url}
to={item.url}
className="flex items-center gap-3 w-full"
>
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
<span className="font-medium text-sm truncate">
{item.title}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{item.title}
</TooltipContent>
</Tooltip>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
© 2025 NAVIS Centre
</div>
</SidebarFooter>
</Sidebar>
</TooltipProvider>
);
}

View File

@ -1,155 +0,0 @@
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CustomPagination } from "@/components/pagination/pagination";
import { useEffect, useState } from "react";
interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void;
onRowClick?: (row: TData) => void;
scrollable?: boolean;
maxHeight?: string;
// Pagination options
enablePagination?: boolean;
defaultPageSize?: number;
pageSizeOptions?: number[];
}
export function VersionTable<TData>({
data,
columns,
isLoading,
onTableInit,
onRowClick,
scrollable = false,
maxHeight = "calc(100vh - 320px)",
enablePagination = false,
defaultPageSize = 10,
pageSizeOptions = [5, 10, 15, 20],
}: VersionTableProps<TData>) {
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: defaultPageSize,
});
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
...(enablePagination && {
getPaginationRowModel: getPaginationRowModel(),
state: { pagination },
onPaginationChange: setPagination,
}),
getRowId: (row: any) => row.id?.toString(),
enableRowSelection: true,
});
useEffect(() => {
onTableInit?.(table);
}, [table, onTableInit]);
const tableContent = (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>Không dữ liệu.</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowClick?.(row.original)}
className={onRowClick ? "cursor-pointer hover:bg-muted/50" : ""}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
);
if (scrollable) {
return (
<div className="space-y-4">
<div className="rounded-md border">
<ScrollArea className="w-full" style={{ height: maxHeight }}>
{tableContent}
</ScrollArea>
</div>
{enablePagination && data.length > 0 && (
<CustomPagination
currentPage={table.getState().pagination.pageIndex + 1}
totalPages={table.getPageCount()}
totalItems={data.length}
itemsPerPage={table.getState().pagination.pageSize}
onPageChange={(page) => table.setPageIndex(page - 1)}
onPageSizeChange={(size) => table.setPageSize(size)}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-md border">{tableContent}</div>
{enablePagination && data.length > 0 && (
<CustomPagination
currentPage={table.getState().pagination.pageIndex + 1}
totalPages={table.getPageCount()}
totalItems={data.length}
itemsPerPage={table.getState().pagination.pageSize}
onPageChange={(page) => table.setPageIndex(page - 1)}
onPageSizeChange={(size) => table.setPageSize(size)}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}

View File

@ -1,109 +0,0 @@
import * as React from "react"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/useMobile"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

View File

@ -0,0 +1,164 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { useForm, formOptions } from "@tanstack/react-form";
import { toast } from "sonner";
import type { AxiosProgressEvent } from "axios";
interface UploadDialogProps {
onSubmit: (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => Promise<void>;
}
const formOpts = formOptions({
defaultValues: { files: new DataTransfer().files, newVersion: "" },
});
export function UploadDialog({ onSubmit }: UploadDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const [uploadPercent, setUploadPercent] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const [isDone, setIsDone] = useState(false);
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
if (!value.newVersion || value.files.length === 0) {
toast.error("Vui lòng điền đầy đủ thông tin");
return;
}
try {
setIsUploading(true);
setUploadPercent(0);
setIsDone(false);
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const progress = Math.round((e.loaded * 100) / e.total);
setUploadPercent(progress);
}
},
});
setIsDone(true);
} catch (error) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
} finally {
setIsUploading(false);
}
},
});
const handleDialogClose = (open: boolean) => {
if (isUploading) return;
setIsOpen(open);
if (!open) {
setUploadPercent(0);
setIsDone(false);
form.reset();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
<DialogTrigger asChild>
<Button>Tải lên phiên bản mới</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cập nhật phiên bản</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field name="newVersion">
{(field) => (
<div>
<Label>Phiên bản</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="1.0.0"
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>
<Label>File</Label>
<Input
type="file"
accept=".exe,.msi,.apk"
onChange={(e) =>
e.target.files && field.handleChange(e.target.files)
}
disabled={isUploading || isDone}
/>
</div>
)}
</form.Field>
{(uploadPercent > 0 || isUploading || isDone) && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
<span>{uploadPercent}%</span>
</div>
<Progress value={uploadPercent} className="w-full" />
</div>
)}
<DialogFooter>
{!isDone ? (
<>
<Button
type="button"
variant="outline"
onClick={() => handleDialogClose(false)}
disabled={isUploading}
>
Hủy
</Button>
<Button type="submit" disabled={isUploading}>
{isUploading ? "Đang tải..." : "Upload"}
</Button>
</>
) : (
<Button type="button" onClick={() => handleDialogClose(false)}>
Hoàn tất
</Button>
)}
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,87 @@
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useEffect } from "react";
interface VersionTableProps<TData> {
data: TData[];
columns: ColumnDef<TData, any>[];
isLoading: boolean;
onTableInit?: (table: any) => void; // <-- thêm
}
export function VersionTable<TData>({
data,
columns,
isLoading,
onTableInit,
}: VersionTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row: any) => row.id?.toString(),
enableRowSelection: true,
});
useEffect(() => {
onTableInit?.(table);
}, [table, onTableInit]);
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>Không dữ liệu.</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
);
}

View File

@ -5,78 +5,29 @@ export const BASE_URL = isDev
: "/api";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
PING: `${BASE_URL}/ping`,
CSRF_TOKEN: `${BASE_URL}/csrf-token`,
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
UPLOAD: `${BASE_URL}/AppVersion/upload`,
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
//blacklist api
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
},
DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
GET_DEVICE_FROM_ROOM: (roomName: string) =>
`${BASE_URL}/DeviceComm/room/${roomName}`,
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
`${BASE_URL}/DeviceComm/clientfolderstatus/${roomName}`,
},
COMMAND: {
ADD_COMMAND: `${BASE_URL}/Command/add`,
GET_COMMANDS: `${BASE_URL}/Command/all`,
GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`,
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
},
SSE_EVENTS: {
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
},
PERMISSION: {
GET_LIST: `${BASE_URL}/Permission/list`,
GET_BY_CATEGORY: `${BASE_URL}/Permission/list-by-category`,
GET_BY_VALUE: (value: number) => `${BASE_URL}/Permission/${value}`,
SEED_FROM_ENUM: `${BASE_URL}/Permission/seed-from-enum`,
GET_DB_LIST: `${BASE_URL}/Permission/db-list`,
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
},
ROLE: {
GET_LIST: `${BASE_URL}/Role/list`,
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
CREATE: `${BASE_URL}/Role/create`,
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
DELETE: (id: number) => `${BASE_URL}/Role/${id}`,
GET_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/permissions`,
ASSIGN_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/assign-permissions`,
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
},
};

View File

@ -1,49 +0,0 @@
import axios from "axios";
// Re-export types from axios for convenience
export type { AxiosProgressEvent, AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
/**
* Axios instance với interceptor tự đng gửi token
*/
const axiosInstance = axios.create();
/**
* Request interceptor - Tự đng thêm Authorization header
*/
axiosInstance.interceptors.request.use(
(config) => {
// Lấy token từ localStorage
const token = localStorage.getItem("token");
// Nếu có token, thêm vào header Authorization
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* Response interceptor - Xử lỗi 401 (Unauthorized)
*/
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Nếu nhận được 401, có thể redirect về trang login
if (error.response?.status === 401) {
// Có thể thêm logic redirect hoặc refresh token ở đây
console.warn("Unauthorized - Token may be expired or invalid");
}
return Promise.reject(error);
}
);
export default axiosInstance;

View File

@ -1,10 +0,0 @@
/**
* System-wide constants
*/
/**
* System Admin priority value
* Priority = 0 means highest permission level (System Admin)
* Lower priority number = Higher permission level
*/
export const SYSTEM_ADMIN_PRIORITY = 0;

View File

@ -1,9 +0,0 @@
import type { Room } from "@/types/room";
import type { SelectItem } from "@/components/dialogs/select-dialog";
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
return rooms.map((room) => ({
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
value: room.name,
}));
}

View File

@ -1,45 +0,0 @@
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
/**
* Check if a priority value indicates System Admin
* @param priority - The priority value to check
* @returns true if the priority is System Admin (0), false otherwise
*/
export function isSystemAdminPriority(priority: number): boolean {
return priority === SYSTEM_ADMIN_PRIORITY;
}
/**
* Check if a priority has higher permission than another
* Lower number = Higher permission (System Admin = 0 is highest)
* @param priority1 - First priority to compare
* @param priority2 - Second priority to compare
* @returns true if priority1 has higher or equal permission than priority2
*/
export function hasHigherOrEqualPriority(priority1: number, priority2: number): boolean {
return priority1 <= priority2;
}
/**
* Compare two priorities
* @param priority1 - First priority to compare
* @param priority2 - Second priority to compare
* @returns -1 if priority1 > priority2, 0 if equal, 1 if priority1 < priority2
*/
export function comparePriorities(priority1: number, priority2: number): -1 | 0 | 1 {
if (priority1 < priority2) return 1; // Lower number = higher permission
if (priority1 > priority2) return -1;
return 0;
}
/**
* Get a human-readable priority label
* @param priority - The priority value
* @returns A label for the priority
*/
export function getPriorityLabel(priority: number): string {
if (priority === SYSTEM_ADMIN_PRIORITY) {
return "System Admin (Highest)";
}
return `Priority ${priority}`;
}

View File

@ -1,17 +0,0 @@
// Auth Queries
export * from "./useAuthQueries";
// App Version Queries
export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Command Queries
export * from "./useCommandQueries";
// Permission Queries
export * from "./usePermissionQueries";
// Role Queries
export * from "./useRoleQueries";

View File

@ -1,186 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as appVersionService from "@/services/app-version.service";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
const APP_VERSION_QUERY_KEYS = {
all: ["app-version"] as const,
agentVersion: () => [...APP_VERSION_QUERY_KEYS.all, "agent"] as const,
softwareList: () => [...APP_VERSION_QUERY_KEYS.all, "software"] as const,
blacklist: () => [...APP_VERSION_QUERY_KEYS.all, "blacklist"] as const,
requiredFiles: () => [...APP_VERSION_QUERY_KEYS.all, "required-files"] as const,
};
/**
* Hook đ lấy danh sách phiên bản agent
*/
export function useGetAgentVersion(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.agentVersion(),
queryFn: () => appVersionService.getAgentVersion(),
enabled,
staleTime: 60 * 1000, // 1 minute
});
}
/**
* Hook đ lấy danh sách phần mềm
*/
export function useGetSoftwareList(enabled = true) {
return useQuery<Version[]>({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
queryFn: () => appVersionService.getSoftwareList(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ upload file
*/
export function useUploadSoftware() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: {
formData: FormData;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
}) => appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
onSuccess: () => {
// Invalidate software list
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}
/**
* Hook đ lấy danh sách blacklist
*/
export function useGetBlacklist(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
queryFn: () => appVersionService.getBlacklist(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm vào blacklist
*/
export function useAddBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addBlacklist(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ xóa khỏi blacklist
*/
export function useDeleteBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (appId: number) => appVersionService.deleteBlacklist(appId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateBlacklist() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ appId, data }: { appId: string; data: any }) =>
appVersionService.updateBlacklist(appId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
});
},
});
}
/**
* Hook đ yêu cầu cập nhật blacklist
*/
export function useRequestUpdateBlacklist() {
return useMutation({
mutationFn: (data: any) => appVersionService.requestUpdateBlacklist(data),
});
}
/**
* Hook đ lấy danh sách file bắt buộc
*/
export function useGetRequiredFiles(enabled = true) {
return useQuery({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
queryFn: () => appVersionService.getRequiredFiles(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ thêm file bắt buộc
*/
export function useAddRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => appVersionService.addRequiredFile(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file bắt buộc
*/
export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
});
},
});
}
/**
* Hook đ xóa file
*/
export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
});
},
});
}

View File

@ -1,115 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as authService from "@/services/auth.service";
import type { CreateAccountRequest } from "@/types/auth";
import type { LoginResquest, LoginResponse } from "@/types/auth";
const AUTH_QUERY_KEYS = {
all: ["auth"] as const,
ping: () => [...AUTH_QUERY_KEYS.all, "ping"] as const,
csrfToken: () => [...AUTH_QUERY_KEYS.all, "csrf-token"] as const,
};
/**
* Hook đ đăng nhập
*/
export function useLogin() {
const queryClient = useQueryClient();
return useMutation<LoginResponse, any, LoginResquest>({
mutationFn: (credentials) => authService.login(credentials),
onSuccess: (data) => {
// Lưu vào localStorage
if (data.token) {
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", JSON.stringify(data.access || []));
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority || "-1"));
}
// Invalidate ping query
queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.ping() });
},
});
}
/**
* Hook đ đăng xuất
*/
export function useLogout() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => authService.logout(),
onSuccess: () => {
localStorage.removeItem("token");
localStorage.removeItem("username");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
// Clear all queries
queryClient.clear();
},
});
}
/**
* Hook đ kiểm tra phiên đăng nhập
*/
export function usePing(token?: string, enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.ping(),
queryFn: () => authService.ping(token),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}
/**
* Hook đ lấy CSRF token
*/
export function useGetCsrfToken(enabled = true) {
return useQuery({
queryKey: AUTH_QUERY_KEYS.csrfToken(),
queryFn: () => authService.getCsrfToken(),
enabled,
staleTime: Infinity,
});
}
/**
* Hook đ thay đi mật khẩu
*/
export function useChangePassword() {
return useMutation({
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
authService.changePassword(data),
});
}
/**
* Hook đ admin thay đi mật khẩu user khác
*/
export function useChangePasswordAdmin() {
return useMutation({
mutationFn: (data: { username: string; newPassword: string }) =>
authService.changePasswordAdmin(data),
});
}
/**
* Hook đ tạo tài khoản mới
*/
export function useCreateAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateAccountRequest) => authService.createAccount(data),
onSuccess: () => {
// Có thể invalidate user list query nếu có
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}

View File

@ -1,84 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as commandService from "@/services/command.service";
const COMMAND_QUERY_KEYS = {
all: ["commands"] as const,
list: () => [...COMMAND_QUERY_KEYS.all, "list"] as const,
detail: (id: number) => [...COMMAND_QUERY_KEYS.all, "detail", id] as const,
};
/**
* Hook đ lấy danh sách lệnh
*/
export function useGetCommandList(enabled = true) {
return useQuery({
queryKey: COMMAND_QUERY_KEYS.list(),
queryFn: () => commandService.getCommandList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
//Hook để lấy lệnh theo loại
export function useGetCommandsByTypes(types: string, enabled = true) {
return useQuery({
queryKey: [...COMMAND_QUERY_KEYS.all, "by-types", types],
queryFn: () => commandService.getCommandsByTypes(types),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ thêm lệnh mới
*/
export function useAddCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => commandService.addCommand(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật lệnh
*/
export function useUpdateCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
commandId,
data,
}: {
commandId: number;
data: any;
}) => commandService.updateCommand(commandId, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ xóa lệnh
*/
export function useDeleteCommand() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (commandId: number) => commandService.deleteCommand(commandId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: COMMAND_QUERY_KEYS.list(),
});
},
});
}

View File

@ -1,172 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
allDevices: () => [...DEVICE_COMM_QUERY_KEYS.all, "all"] as const,
roomList: () => [...DEVICE_COMM_QUERY_KEYS.all, "rooms"] as const,
devicesInRoom: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "room", roomName] as const,
clientFolderStatus: (roomName: string) =>
[...DEVICE_COMM_QUERY_KEYS.all, "folder-status", roomName] as const,
};
/**
* Hook đ lấy tất cả thiết bị
*/
export function useGetAllDevices(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.allDevices(),
queryFn: () => deviceCommService.getAllDevices(),
enabled,
staleTime: 60 * 1000,
});
}
/**
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy danh sách thiết bị trong phòng
*/
export function useGetDeviceFromRoom(roomName?: string, enabled = true) {
return useQuery<DeviceHealthCheck[]>({
queryKey: roomName ? DEVICE_COMM_QUERY_KEYS.devicesInRoom(roomName) : ["disabled"],
queryFn: () =>
roomName ? deviceCommService.getDeviceFromRoom(roomName) : Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000, // 30 seconds
});
}
/**
* Hook đ tải file
*/
export function useDownloadFiles() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.downloadFiles(roomName, data),
});
}
/**
* Hook đ cài đt MSI
*/
export function useInstallMsi() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.installMsi(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật agent
*/
export function useUpdateAgent() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateAgent(roomName, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ cập nhật blacklist
*/
export function useUpdateDeviceBlacklist() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.updateBlacklist(roomName, data),
});
}
/**
* Hook đ gửi lệnh shell
*/
export function useSendCommand() {
return useMutation({
mutationFn: ({
roomName,
data,
}: {
roomName: string;
data: any;
}) => deviceCommService.sendCommand(roomName, data),
});
}
/**
* Hook đ thay đi phòng của thiết bị
*/
export function useChangeDeviceRoom() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => deviceCommService.changeDeviceRoom(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: DEVICE_COMM_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ lấy trạng thái folder client
*/
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
return useQuery({
queryKey: roomName
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
: ["disabled"],
queryFn: () =>
roomName
? deviceCommService.getClientFolderStatus(roomName)
: Promise.reject("No room"),
enabled: enabled && !!roomName,
staleTime: 30 * 1000,
});
}

View File

@ -1,90 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as permissionService from "@/services/permission.service";
export const PERMISSION_QUERY_KEYS = {
all: ["permissions"] as const,
list: () => [...PERMISSION_QUERY_KEYS.all, "list"] as const,
dbList: () => [...PERMISSION_QUERY_KEYS.all, "db-list"] as const,
byCategory: () => [...PERMISSION_QUERY_KEYS.all, "by-category"] as const,
detail: (value: number) => [...PERMISSION_QUERY_KEYS.all, "detail", value] as const,
};
/**
* Hook đ lấy danh sách permission từ enum
*/
export function useGetPermissionList(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.list(),
queryFn: () => permissionService.getPermissionList(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy permission theo category
*/
export function useGetPermissionByCategory(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.byCategory(),
queryFn: () => permissionService.getPermissionByCategory(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy chi tiết permission theo value
*/
export function useGetPermissionByValue(value: number, enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.detail(value),
queryFn: () => permissionService.getPermissionByValue(value),
enabled: enabled && value > 0,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ lấy danh sách permission từ database
*/
export function useGetPermissionDbList(enabled = true) {
return useQuery({
queryKey: PERMISSION_QUERY_KEYS.dbList(),
queryFn: () => permissionService.getPermissionDbList(),
enabled,
staleTime: 10 * 60 * 1000, // 10 minutes
});
}
/**
* Hook đ seed permission từ enum vào DB
*/
export function useSeedPermissionFromEnum() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => permissionService.seedPermissionFromEnum(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PERMISSION_QUERY_KEYS.all,
});
},
});
}
/**
* Hook đ xóa permission
*/
export function useDeletePermission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => permissionService.deletePermission(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PERMISSION_QUERY_KEYS.all,
});
},
});
}

View File

@ -1,149 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as roleService from "@/services/role.service";
import type { TCreateRoleRequestBody } from "@/types/role";
export const ROLE_QUERY_KEYS = {
all: ["roles"] as const,
list: () => [...ROLE_QUERY_KEYS.all, "list"] as const,
detail: (id: number) => [...ROLE_QUERY_KEYS.all, "detail", id] as const,
permissions: (id: number) => [...ROLE_QUERY_KEYS.all, "permissions", id] as const,
};
/**
* Hook đ lấy danh sách roles
*/
export function useGetRoleList(enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.list(),
queryFn: () => roleService.getRoleList(),
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy chi tiết role theo ID
*/
export function useGetRoleById(id: number, enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.detail(id),
queryFn: () => roleService.getRoleById(id),
enabled: enabled && id > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ lấy danh sách permissions của role
*/
export function useGetRolePermissions(id: number, enabled = true) {
return useQuery({
queryKey: ROLE_QUERY_KEYS.permissions(id),
queryFn: () => roleService.getRolePermissions(id),
enabled: enabled && id > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ tạo role mới
*/
export function useCreateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: TCreateRoleRequestBody) => roleService.createRole(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role
*/
export function useUpdateRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: Partial<TCreateRoleRequestBody>;
}) => roleService.updateRole(id, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.detail(variables.id),
});
},
});
}
/**
* Hook đ xóa role
*/
export function useDeleteRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => roleService.deleteRole(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ gán permissions cho role
*/
export function useAssignRolePermissions() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roleId,
permissionIds,
}: {
roleId: number;
permissionIds: number[];
}) => roleService.assignRolePermissions(roleId, permissionIds),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
});
},
});
}
/**
* Hook đ bật/tắt một permission của role
*/
export function useToggleRolePermission() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
roleId,
permissionId,
isChecked,
}: {
roleId: number;
permissionId: number;
isChecked: boolean;
}) => roleService.toggleRolePermission(roleId, permissionId, isChecked),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
});
},
});
}

View File

@ -1,113 +0,0 @@
import { sleep } from "@/lib/utils";
import { PermissionEnum } from "@/types/permission";
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
import React, { useContext, useEffect, useMemo } from "react";
import { useCallback, useState } from "react";
export interface IAuthContext {
isAuthenticated: boolean;
setAuthenticated: (value: boolean) => void;
logout: () => Promise<void>;
login: (username: string) => void;
username: string;
token: string;
name: string;
acs: number[];
hasPermission: (permission: PermissionEnum) => boolean;
isSystemAdmin: () => boolean;
role: {
roleName: string;
priority: number;
};
}
const AuthContext = React.createContext<IAuthContext | null>(null);
const key = "computersmanagement.auth.user";
function getStoredUser() {
return localStorage.getItem(key);
}
function setStoredUser(user: string | null) {
if (user) {
localStorage.setItem(key, user);
} else {
localStorage.removeItem(key);
}
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<string>(getStoredUser() || "");
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!user);
const token = localStorage.getItem("token") || "";
const name = localStorage.getItem("name") || "";
const acsString = localStorage.getItem("acs");
const acs = useMemo(() => (acsString ? acsString.split(",").map(Number) : []), [acsString]);
const roleName = localStorage.getItem("role") || "";
const priority = localStorage.getItem("priority") || "-1";
const setAuthenticated = useCallback((value: boolean) => {
setIsAuthenticated(value);
}, []);
const login = useCallback((username: string) => {
setStoredUser(username);
setUser(username);
}, []);
const hasPermission = useCallback(
(permission: PermissionEnum) => {
return acs.some((a) => a === permission);
},
[acs]
);
const isSystemAdmin = useCallback(() => {
return Number(priority) === SYSTEM_ADMIN_PRIORITY;
}, [priority]);
const logout = useCallback(async () => {
await sleep(250);
setAuthenticated(false);
setStoredUser("");
setUser("");
localStorage.removeItem("token");
localStorage.removeItem("name");
localStorage.removeItem("acs");
localStorage.removeItem("role");
localStorage.removeItem("priority");
}, [setAuthenticated]);
useEffect(() => {
setUser(getStoredUser() || "");
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
setAuthenticated,
login,
logout,
username: user,
token,
name,
acs,
role: { roleName, priority: Number(priority) },
hasPermission,
isSystemAdmin
}}>
{children}
</AuthContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@ -1,83 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS } from "@/config/api";
export interface MissingFiles {
fileName: string;
folderPath: string;
}
export interface ExtraFiles {
fileName: string;
folderPath: string;
}
export interface ClientFolderStatus {
id: number;
deviceId: string;
missingFiles: MissingFiles[];
extraFiles: ExtraFiles[];
createdAt: string;
updatedAt: string;
}
export function useClientFolderStatus(roomName?: string) {
const queryClient = useQueryClient();
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [folderStatuses, setFolderStatuses] = useState<
Map<string, ClientFolderStatus>
>(new Map());
useEffect(() => {
let eventSource: EventSource | null = null;
const connect = () => {
eventSource = new EventSource(
API_ENDPOINTS.SSE_EVENTS.GET_CLIENT_FOLDER_STATUS
);
eventSource.addEventListener("clientFolderStatus", (event) => {
try {
const data: ClientFolderStatus = JSON.parse(event.data);
if (roomName && data.deviceId) {
setFolderStatuses((prev) => {
const newMap = new Map(prev);
newMap.set(data.deviceId, data);
return newMap;
});
// Also cache in React Query for persistence
queryClient.setQueryData(
["folderStatus", data.deviceId],
data
);
}
} catch (err) {
console.error("Error parsing clientFolderStatus event:", err);
}
});
const onError = (err: any) => {
console.error("SSE connection error:", err);
cleanup();
reconnectTimeout.current = setTimeout(connect, 5000);
};
eventSource.onerror = onError;
};
const cleanup = () => {
if (eventSource) eventSource.close();
if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current);
reconnectTimeout.current = null;
}
};
connect();
return cleanup;
}, [roomName, queryClient]);
return folderStatuses;
}

View File

@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import axios, { type Method } from "axios";
type MutationDataOptions<TInput, TOutput> = {
url: string;
method?: Method;
onSuccess?: (data: TOutput) => void;
onError?: (error: any) => void;
invalidate?: string[][];
};
export function useMutationData<TInput = any, TOutput = any>({
url,
method = "POST",
onSuccess,
onError,
invalidate = [],
}: MutationDataOptions<TInput, TOutput>) {
const queryClient = useQueryClient();
return useMutation<
TOutput,
any,
{ data: TInput; url?: string; config?: any; method?: Method }
>({
mutationFn: async ({
data,
config,
url: customUrl,
method: customMethod,
}) => {
const isFormData = data instanceof FormData;
const response = await axios.request({
url: customUrl ?? url,
method: customMethod ?? method,
data,
headers: {
...(isFormData ? {} : { "Content-Type": "application/json" }),
},
...config,
});
return response.data;
},
onSuccess: (data) => {
invalidate.forEach((key) =>
queryClient.invalidateQueries({ queryKey: key })
);
onSuccess?.(data);
},
onError,
});
}

26
src/hooks/useQueryData.ts Normal file
View File

@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
type QueryDataOptions<T> = {
queryKey: string[];
url: string;
params?: Record<string, any>;
select?: (data: any) => T;
enabled?: boolean;
}
export function useQueryData<T = any>({
queryKey,
url,
params,
select,
enabled = true,
}: QueryDataOptions<T>) {
return useQuery<T>({
queryKey,
queryFn: () => axios.get(url, { params }).then((res) => res.data),
select,
enabled,
})
}

View File

@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import { AppSidebar } from "@/components/app-sidebar";
import {
SidebarProvider,
SidebarInset,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Building } from "lucide-react";
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
import { Toaster } from "@/components/ui/sonner";
import { useQueryClient } from "@tanstack/react-query";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { Separator } from "@/components/ui/separator";
type AppLayoutProps = {
@ -14,10 +16,84 @@ type AppLayoutProps = {
};
export default function AppLayout({ children }: AppLayoutProps) {
const queryClient = useQueryClient();
const handlePrefetchAgents = () => {
queryClient.prefetchQuery({
queryKey: ["agent-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchSofware = () => {
queryClient.prefetchQuery({
queryKey: ["software-version"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchRooms = () => {
queryClient.prefetchQuery({
queryKey: ["room-list"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const handlePrefetchBannedSoftware = () => {
queryClient.prefetchQuery({
queryKey: ["blacklist"],
queryFn: () =>
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
res.json()
),
staleTime: 60 * 1000,
});
};
const items = [
{ title: "Dashboard", to: "/", icon: Home },
{
title: "Danh sách phòng",
to: "/room",
icon: Building,
onPointerEnter: handlePrefetchRooms,
},
{
title: "Quản lý Agent",
to: "/agent",
icon: AppWindow,
onPointerEnter: handlePrefetchAgents,
},
{
title: "Quản lý phần mềm",
to: "/apps",
icon: AppWindow,
onPointerEnter: handlePrefetchSofware,
},
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
{
title: "Danh sách đen",
to: "/blacklist",
icon: CircleX,
onPointerEnter: handlePrefetchBannedSoftware,
},
];
return (
<SidebarProvider>
<div className="flex min-h-screen w-full bg-background">
<AppSidebar />
<AppSidebar items={items} />
<SidebarInset className="flex-1">
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/40 bg-background/95 backdrop-blur px-4 lg:hidden supports-[backdrop-filter]:bg-background/60">
<SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />

View File

@ -1,35 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { format } from "date-fns";
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const DEFAULT_DATE_FORMAT = "dd/MM/yyyy";
export function getCurrentTimeUTC(): string {
const date = new Date();
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
const day = date.getUTCDate().toString().padStart(2, "0");
const year = date.getUTCFullYear();
const hour = date.getUTCHours().toString().padStart(2, "0");
const min = date.getUTCMinutes().toString().padStart(2, "0");
return `${day}/${month}/${year} ${hour}:${min}`;
}
export function formatDate(
date: string | Date | undefined | null,
formatTemplate = DEFAULT_DATE_FORMAT
) {
if (date == undefined) return "";
if (date == null) return "";
return format(new Date(date), formatTemplate, {});
}

View File

@ -1,24 +1,25 @@
/* eslint-disable react-refresh/only-export-components */
import { StrictMode } from "react";
import "./index.css";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import useAuthToken from "./hooks/useAuthtoken";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axios from "axios";
import { AuthProvider, useAuth } from "@/hooks/useAuth";
import { toast, Toaster } from "sonner";
import "./styles.css";
const auth = useAuthToken.getState();
const queryClient = new QueryClient();
// Create a new router instance
const router = createRouter({
routeTree,
context: { auth },
defaultPreload: "intent",
scrollRestoration: true,
context: {
auth: undefined!, // This will be set after we initialize the auth store
queryClient: undefined!
}
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
// Register the router instance for type safety
@ -26,61 +27,18 @@ declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
interface HistoryState {
name?: string;
}
}
function InnerApp() {
const auth = useAuth();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: unknown) => {
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
if (error.response?.data.message != null) {
toast.error("Không có quyền truy cập!");
return false;
} else {
auth.logout();
queryClient.invalidateQueries();
queryClient.clear();
return false;
}
}
}
return failureCount < 3;
}
}
}
});
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ auth, queryClient }} />
</QueryClientProvider>
);
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
);
}
// Render the app
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("Failed to find the root element");
}
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<App />
<Toaster richColors />
<QueryClientProvider client={queryClient}>
{" "}
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>
);
}

View File

@ -9,27 +9,22 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
import { Route as AuthUserRoleIndexRouteImport } from './routes/_auth/user/role/index'
import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index'
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/index'
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
@ -39,164 +34,86 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoomsIndexRoute = AuthRoomsIndexRouteImport.update({
id: '/rooms/',
path: '/rooms/',
getParentRoute: () => AuthRoute,
const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
id: '/room/',
path: '/room/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
id: '/role/',
path: '/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
const AuthenticatedDeviceIndexRoute =
AuthenticatedDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
const AuthenticatedCommandIndexRoute =
AuthenticatedCommandIndexRouteImport.update({
id: '/command/',
path: '/command/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthCommandsIndexRoute = AuthCommandsIndexRouteImport.update({
id: '/commands/',
path: '/commands/',
getParentRoute: () => AuthRoute,
const AuthenticatedBlacklistIndexRoute =
AuthenticatedBlacklistIndexRouteImport.update({
id: '/blacklist/',
path: '/blacklist/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
id: '/blacklists/',
path: '/blacklists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthAgentIndexRoute = AuthAgentIndexRouteImport.update({
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
id: '/agent/',
path: '/agent/',
getParentRoute: () => AuthRoute,
getParentRoute: () => AuthenticatedRoute,
} as any)
const authLoginIndexRoute = authLoginIndexRouteImport.update({
id: '/(auth)/login/',
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleIndexRoute = AuthUserRoleIndexRouteImport.update({
id: '/user/role/',
path: '/user/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({
id: '/user/create/',
path: '/user/create/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameIndexRoute = AuthRoomsRoomNameIndexRouteImport.update({
id: '/rooms/$roomName/',
path: '/rooms/$roomName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleCreateIndexRoute = AuthRoleCreateIndexRouteImport.update({
id: '/role/create/',
path: '/role/create/',
getParentRoute: () => AuthRoute,
} as any)
const AuthProfileChangePasswordIndexRoute =
AuthProfileChangePasswordIndexRouteImport.update({
id: '/profile/change-password/',
path: '/profile/change-password/',
getParentRoute: () => AuthRoute,
} as any)
const AuthProfileUserNameIndexRoute =
AuthProfileUserNameIndexRouteImport.update({
id: '/profile/$userName/',
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/',
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
path: '/user/change-password/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
id: '/role/$id/edit/',
path: '/role/$id/edit/',
getParentRoute: () => AuthRoute,
const AuthenticatedRoomRoomNameIndexRoute =
AuthenticatedRoomRoomNameIndexRouteImport.update({
id: '/room/$roomName/',
path: '/room/$roomName/',
getParentRoute: () => AuthenticatedRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/user/role': typeof AuthUserRoleIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/user/role': typeof AuthUserRoleIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
'/login': typeof AuthLoginIndexRoute
'/agent': typeof AuthenticatedAgentIndexRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
'/command': typeof AuthenticatedCommandIndexRoute
'/device': typeof AuthenticatedDeviceIndexRoute
'/room': typeof AuthenticatedRoomIndexRoute
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
'/_auth/commands/': typeof AuthCommandsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
'/_auth/user/role/': typeof AuthUserRoleIndexRoute
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/_auth/login/': typeof AuthLoginIndexRoute
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -205,74 +122,52 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/blacklist'
| '/command'
| '/device'
| '/role'
| '/rooms'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/user/role'
| '/role/$id/edit'
| '/user/change-password/$userName'
| '/user/role/$roleId'
| '/room'
| '/room/$roomName'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/agent'
| '/apps'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/blacklist'
| '/command'
| '/device'
| '/role'
| '/rooms'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/user/role'
| '/role/$id/edit'
| '/user/change-password/$userName'
| '/user/role/$roleId'
| '/room'
| '/room/$roomName'
id:
| '__root__'
| '/'
| '/_auth'
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/blacklists/'
| '/_auth/commands/'
| '/_auth/dashboard/'
| '/_auth/device/'
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
| '/_auth/rooms/$roomName/'
| '/_auth/user/create/'
| '/_auth/user/role/'
| '/_auth/role/$id/edit/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/role/$roleId/'
| '/_authenticated'
| '/_auth/login/'
| '/_authenticated/agent/'
| '/_authenticated/apps/'
| '/_authenticated/blacklist/'
| '/_authenticated/command/'
| '/_authenticated/device/'
| '/_authenticated/room/'
| '/_authenticated/room/$roomName/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
@ -287,182 +182,103 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/rooms/': {
id: '/_auth/rooms/'
path: '/rooms'
fullPath: '/rooms'
preLoaderRoute: typeof AuthRoomsIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/room/': {
id: '/_authenticated/room/'
path: '/room'
fullPath: '/room'
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/role/': {
id: '/_auth/role/'
path: '/role'
fullPath: '/role'
preLoaderRoute: typeof AuthRoleIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/device/': {
id: '/_auth/device/'
'/_authenticated/device/': {
id: '/_authenticated/device/'
path: '/device'
fullPath: '/device'
preLoaderRoute: typeof AuthDeviceIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/command/': {
id: '/_authenticated/command/'
path: '/command'
fullPath: '/command'
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/commands/': {
id: '/_auth/commands/'
path: '/commands'
fullPath: '/commands'
preLoaderRoute: typeof AuthCommandsIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/blacklist/': {
id: '/_authenticated/blacklist/'
path: '/blacklist'
fullPath: '/blacklist'
preLoaderRoute: typeof AuthenticatedBlacklistIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/blacklists/': {
id: '/_auth/blacklists/'
path: '/blacklists'
fullPath: '/blacklists'
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
'/_authenticated/apps/': {
id: '/_authenticated/apps/'
path: '/apps'
fullPath: '/apps'
preLoaderRoute: typeof AuthAppsIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/agent/': {
id: '/_auth/agent/'
'/_authenticated/agent/': {
id: '/_authenticated/agent/'
path: '/agent'
fullPath: '/agent'
preLoaderRoute: typeof AuthAgentIndexRouteImport
parentRoute: typeof AuthRoute
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/(auth)/login/': {
id: '/(auth)/login/'
'/_auth/login/': {
id: '/_auth/login/'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof authLoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/': {
id: '/_auth/user/role/'
path: '/user/role'
fullPath: '/user/role'
preLoaderRoute: typeof AuthUserRoleIndexRouteImport
preLoaderRoute: typeof AuthLoginIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/create/': {
id: '/_auth/user/create/'
path: '/user/create'
fullPath: '/user/create'
preLoaderRoute: typeof AuthUserCreateIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/': {
id: '/_auth/rooms/$roomName/'
path: '/rooms/$roomName'
fullPath: '/rooms/$roomName'
preLoaderRoute: typeof AuthRoomsRoomNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/create/': {
id: '/_auth/role/create/'
path: '/role/create'
fullPath: '/role/create'
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/change-password/': {
id: '/_auth/profile/change-password/'
path: '/profile/change-password'
fullPath: '/profile/change-password'
preLoaderRoute: typeof AuthProfileChangePasswordIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/profile/$userName/': {
id: '/_auth/profile/$userName/'
path: '/profile/$userName'
fullPath: '/profile/$userName'
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/role/$roleId/': {
id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId'
fullPath: '/user/role/$roleId'
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
fullPath: '/user/change-password/$userName'
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/$id/edit/': {
id: '/_auth/role/$id/edit/'
path: '/role/$id/edit'
fullPath: '/role/$id/edit'
preLoaderRoute: typeof AuthRoleIdEditIndexRouteImport
parentRoute: typeof AuthRoute
'/_authenticated/room/$roomName/': {
id: '/_authenticated/room/$roomName/'
path: '/room/$roomName'
fullPath: '/room/$roomName'
preLoaderRoute: typeof AuthenticatedRoomRoomNameIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
}
}
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
AuthUserRoleIndexRoute: typeof AuthUserRoleIndexRoute
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
AuthLoginIndexRoute: typeof AuthLoginIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRoleIndexRoute: AuthRoleIndexRoute,
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthUserRoleIndexRoute: AuthUserRoleIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
AuthLoginIndexRoute: AuthLoginIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -1,18 +0,0 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { LoginForm } from '@/components/forms/login-form'
export const Route = createFileRoute('/(auth)/login/')({
beforeLoad: async ({ context }) => {
const { token } = context.auth
if (token) throw redirect({ to: '/' })
},
component: LoginPage,
})
function LoginPage() {
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<LoginForm className="w-full max-w-md" />
</div>
)
}

View File

@ -1,37 +1,22 @@
import ErrorRoute from "@/components/pages/error-route";
import NotFound from "@/components/pages/not-found";
import { type IAuthContext } from "@/types/auth";
import { QueryClient } from "@tanstack/react-query";
import {
createRootRouteWithContext,
HeadContent,
Outlet,
} from "@tanstack/react-router";
import { Outlet, createRootRouteWithContext, HeadContent } from '@tanstack/react-router'
import type { AuthTokenProps } from '@/hooks/useAuthtoken'
export interface BreadcrumbItem {
title: string;
path: string;
export interface RouterContext {
auth: AuthTokenProps
}
export interface MyRouterContext {
auth: IAuthContext;
queryClient: QueryClient;
breadcrumbs?: BreadcrumbItem[];
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => {
return (
export const Route = createRootRouteWithContext<RouterContext>()({
head: () => ({
meta: [
{ title: "Quản lý phòng máy" },
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
],
}),
component: () => (
<>
<HeadContent />
<Outlet />
</>
);
},
notFoundComponent: () => {
return <NotFound />;
},
errorComponent: ({ error }) => {
return <ErrorRoute error={error.message} />;
},
});
),
})

View File

@ -1,87 +1,16 @@
import { useEffect } from "react";
import AppBreadCrumb from "@/components/app-breadcrumb";
import { AppSidebar } from "@/components/sidebars/app-sidebar";
import AvatarDropdown from "@/components/avatar-dropdown";
import SessionTimeOutErrorPage from "@/components/pages/session-timeout-error";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
import { useUIStore } from "@/stores/uiStore";
import { useAuth } from "@/hooks/useAuth";
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
export const Route = createFileRoute("/_auth")({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: "/login",
search: {
redirect: location.href
}
});
export const Route = createFileRoute('/_auth')({
beforeLoad: async ({context}) => {
const {authToken} = context.auth
if (authToken) {
throw redirect({to: '/'})
}
},
component: RouteComponent
});
function RouteComponent() {
const auth = useAuth();
const setCurrent = useUIStore((state) => state.setCurrent);
const router = useRouter();
const navigate = Route.useNavigate();
const currentPath = router.state.location.pathname;
// Update current path in UI store when location changes
useEffect(() => {
setCurrent(currentPath);
}, [currentPath, setCurrent]);
const handleLogout = () => {
if (window.confirm("Bạn chắc chắn muốn đăng xuất khỏi hệ thống?")) {
auth.logout().then(() => {
router.invalidate().finally(() => {
navigate({ to: "/" });
});
});
}
};
const handleProfile = () => {
navigate({ to: "/profile/$userName", params: { userName: auth.username } } as any);
};
const handleChangePassword = () => {
navigate({ to: "/profile/change-password" } as any);
};
const username = auth.username;
if (!auth.isAuthenticated) {
return <SessionTimeOutErrorPage />;
}
component:AuthLayout ,
})
function AuthLayout() {
return (
<SidebarProvider className="h-screen w-screen">
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<AppBreadCrumb />
<div className="flex items-center gap-4 ml-auto">
<AvatarDropdown
username={username}
role={auth.role}
onLogOut={handleLogout}
onProfile={handleProfile}
onChangePassword={handleChangePassword}
/>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
);
)
}

View File

@ -1,301 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetSoftwareList,
useGetRoomList,
useUploadSoftware,
useDeleteFile,
useAddRequiredFile,
useDeleteRequiredFile,
useInstallMsi,
useDownloadFiles,
} from "@/hooks/queries";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { Check, X } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý phần mềm", path: "/_auth/apps/" },
];
},
});
function AppsComponent() {
const { data, isLoading } = useGetSoftwareList();
const { data: roomData } = useGetRoomList();
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useUploadSoftware();
const installMutation = useInstallMsi();
const downloadMutation = useDownloadFiles();
const deleteMutation = useDeleteFile();
const addRequiredFileMutation = useAddRequiredFile();
const deleteRequiredFileMutation = useDeleteRequiredFile();
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đt/tải xuống</div>,
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
id: "required",
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
cell: ({ row }) => {
const isRequired = row.original.isRequired;
return isRequired ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: () => <div className="whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
};
// Callback khi chọn phòng
const handleInstall = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await installMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cài đặt!");
}
};
const handleDonwload = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để tải!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
for (const roomName of roomNames) {
await downloadMutation.mutateAsync({
roomName,
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu tải file cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi tải!");
}
};
const handleDelete = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để xóa!");
return;
}
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm thành công!");
} catch (e) {
toast.error("Xóa phần mềm thất bại!");
}
};
const handleDeleteFromRequiredList = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteRequiredFileMutation.mutateAsync(id);
}
toast.success("Xóa file khỏi danh sách thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete from required list error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleDeleteFromServer = async () => {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
try {
for (const row of selectedRows) {
const { id } = row.original;
await deleteMutation.mutateAsync(id);
}
toast.success("Xóa phần mềm từ server thành công!");
if (table) {
table.setRowSelection({});
}
} catch (e) {
console.error("Delete error:", e);
toast.error("Có lỗi xảy ra khi xóa!");
}
};
const handleAddToRequired = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file!");
return;
}
try {
for (const row of selectedRows) {
const { fileName, version } = row.original;
await addRequiredFileMutation.mutateAsync({
fileName,
version,
});
}
toast.success("Thêm file vào danh sách thành công!");
table.setRowSelection({});
} catch (e) {
console.error("Add required file error:", e);
toast.error("Có lỗi xảy ra!");
}
};
return (
<>
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
onDownload={handleDonwload}
onDelete={handleDelete}
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
addToRequiredLoading={addRequiredFileMutation.isPending}
onTableInit={setTable}
rooms={roomData}
enablePagination
defaultPageSize={10}
/>
</>
);
}

View File

@ -1,166 +0,0 @@
import {
useGetBlacklist,
useGetRoomList,
useAddBlacklist,
useDeleteBlacklist,
useUpdateDeviceBlacklist,
} from "@/hooks/queries";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import type { Blacklist } from "@/types/black-list";
import { BlackListManagerTemplate } from "@/template/table-manager-template";
import { toast } from "sonner";
import { useState } from "react";
export const Route = createFileRoute("/_auth/blacklists/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý danh sách chặn", path: "/_auth/blacklists/" },
];
},
});
function BlacklistComponent() {
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
// Lấy danh sách blacklist
const { data, isLoading } = useGetBlacklist();
// Lấy danh sách phòng
const { data: roomData = [] } = useGetRoomList();
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns: ColumnDef<Blacklist>[] = [
{
accessorKey: "id",
header: "STT",
cell: (info) => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: (info) => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: (info) => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: (info) => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: (info) => info.getValue(),
},
{
id: "select",
header: () => (
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked && data) {
const allIds = data.map((item: { id: number }) => item.id);
setSelectedRows(new Set(allIds));
} else {
setSelectedRows(new Set());
}
}}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={selectedRows.has(row.original.id)}
onChange={(e) => {
const newSelected = new Set(selectedRows);
if (e.target.checked) {
newSelected.add(row.original.id);
} else {
newSelected.delete(row.original.id);
}
setSelectedRows(newSelected);
}}
/>
),
},
];
// API mutations
const addNewBlacklistMutation = useAddBlacklist();
const deleteBlacklistMutation = useDeleteBlacklist();
const updateDeviceMutation = useUpdateDeviceBlacklist();
// Thêm blacklist
const handleAddNewBlacklist = async (blacklistData: {
appName: string;
processName: string;
}) => {
try {
await addNewBlacklistMutation.mutateAsync(blacklistData);
toast.success("Thêm mới thành công!");
} catch (error: any) {
console.error("Add blacklist error:", error);
toast.error("Thêm mới thất bại!");
}
};
// Xoá blacklist
const handleDeleteBlacklist = async () => {
try {
for (const blacklistId of selectedRows) {
await deleteBlacklistMutation.mutateAsync(blacklistId);
}
toast.success("Xóa thành công!");
setSelectedRows(new Set());
} catch (error: any) {
console.error("Delete blacklist error:", error);
toast.error("Xóa thất bại!");
}
};
const handleUpdateDevice = async (target: string | string[]) => {
const targets = Array.isArray(target) ? target : [target];
try {
for (const roomName of targets) {
await updateDeviceMutation.mutateAsync({
roomName,
data: {},
});
toast.success(`Đã gửi cập nhật cho ${roomName}`);
}
} catch (e: any) {
console.error("Update device error:", e);
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
return (
<BlackListManagerTemplate<Blacklist>
title="Danh sách các ứng dụng bị chặn"
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
data={blacklist}
columns={columns}
isLoading={isLoading}
rooms={roomData}
onAdd={handleAddNewBlacklist}
onDelete={handleDeleteBlacklist}
onUpdate={handleUpdateDevice}
enablePagination
defaultPageSize={10}
/>
);
}

View File

@ -1,406 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
useGetRoomList,
useAddCommand,
useUpdateCommand,
useDeleteCommand,
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { ColumnDef } from "@tanstack/react-table";
import type { ShellCommandData } from "@/components/forms/command-form";
import type { CommandRegistry } from "@/types/command-registry";
export const Route = createFileRoute("/_auth/commands/")({
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
component: CommandPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
];
},
});
function CommandPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
const [detailPanelCommand, setDetailPanelCommand] = useState<CommandRegistry | null>(null);
const [table, setTable] = useState<any>();
// Fetch commands
const { data: commands = [], isLoading } = useGetCommandList();
// Fetch rooms
const { data: roomData = [] } = useGetRoomList();
const commandList: CommandRegistry[] = Array.isArray(commands)
? commands.map((cmd: any) => ({
...cmd,
qoS: cmd.qoS ?? 0,
isRetained: cmd.isRetained ?? false,
}))
: [];
// Mutations
const addCommandMutation = useAddCommand();
const updateCommandMutation = useUpdateCommand();
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
accessorKey: "commandName",
header: "Tên lệnh",
size: 100,
cell: ({ getValue }) => (
<div className="max-w-[100px]">
<span className="font-semibold truncate block">{getValue() as string}</span>
</div>
),
},
{
accessorKey: "commandType",
header: "Loại lệnh",
cell: ({ getValue }) => {
const type = getValue() as number;
const typeMap: Record<number, string> = {
1: "RESTART",
2: "SHUTDOWN",
3: "TASKKILL",
4: "BLOCK",
};
return <span>{typeMap[type] || "UNKNOWN"}</span>;
},
},
{
accessorKey: "description",
header: "Mô tả",
size: 120,
cell: ({ getValue }) => (
<div className="max-w-[120px]">
<span className="text-sm text-muted-foreground truncate block">
{(getValue() as string) || "-"}
</span>
</div>
),
},
{
accessorKey: "commandContent",
header: "Nội dung lệnh",
size: 130,
cell: ({ getValue }) => (
<div className="max-w-[130px]">
<code className="text-xs bg-muted/50 px-1.5 py-0.5 rounded truncate block">
{(getValue() as string).substring(0, 40)}...
</code>
</div>
),
},
{
accessorKey: "qoS",
header: "QoS",
cell: ({ getValue }) => {
const qos = getValue() as number | undefined;
const qosValue = qos !== undefined ? qos : 0;
const colors = {
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
};
return (
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
);
},
},
{
accessorKey: "isRetained",
header: "Lưu trữ",
cell: ({ getValue }) => {
const retained = getValue() as boolean;
return retained ? (
<div className="flex items-center gap-1">
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</div>
) : (
<div className="flex items-center gap-1">
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</div>
);
},
},
{
id: "select",
header: () => <div className="text-center text-xs">Chọn đ thực thi</div>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => <div className="text-center text-xs">Hành đng</div>,
cell: ({ row }) => (
<div className="flex gap-2 justify-center">
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setSelectedCommand(row.original);
setIsDialogOpen(true);
}}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeleteCommand(row.original.id);
}}
disabled={deleteCommandMutation.isPending}
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
),
enableSorting: false,
enableHiding: false,
},
];
// Handle form submit
const handleFormSubmit = async (data: CommandRegistryFormData) => {
try {
if (selectedCommand) {
// Update
await updateCommandMutation.mutateAsync({
commandId: selectedCommand.id,
data,
});
} else {
// Add
await addCommandMutation.mutateAsync(data);
}
setIsDialogOpen(false);
setSelectedCommand(null);
toast.success(selectedCommand ? "Cập nhật lệnh thành công!" : "Thêm lệnh thành công!");
} catch (error) {
console.error("Form submission error:", error);
toast.error(selectedCommand ? "Cập nhật lệnh thất bại!" : "Thêm lệnh thất bại!");
}
};
// Handle delete
const handleDeleteCommand = async (commandId: number) => {
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
try {
await deleteCommandMutation.mutateAsync(commandId);
toast.success("Xóa lệnh thành công!");
} catch (error) {
console.error("Delete error:", error);
toast.error("Xóa lệnh thất bại!");
}
};
// Handle execute commands from list
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một lệnh để thực thi!");
return;
}
try {
for (const target of targets) {
for (const row of selectedRows) {
// API expects PascalCase directly
const apiData = {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
};
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
}
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
if (table) {
table.setRowSelection({});
}
} catch (error) {
toast.error("Có lỗi xảy ra khi thực thi!");
}
};
// Handle execute custom command
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
const apiData = {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
};
await sendCommandMutation.mutateAsync({
roomName: target,
data: apiData as any,
});
}
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
} catch (error) {
toast.error("Gửi lệnh tùy chỉnh thất bại!");
}
};
return (
<>
<CommandSubmitTemplate
title="Gửi lệnh từ xa"
description="Quản lý và thực thi các lệnh trên thiết bị"
data={commandList}
isLoading={isLoading}
columns={columns}
dialogOpen={isDialogOpen}
onDialogOpen={setIsDialogOpen}
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
onAddNew={() => {
setSelectedCommand(null);
setIsDialogOpen(true);
}}
onTableInit={setTable}
formContent={
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}
onExecuteSelected={handleExecuteSelected}
onExecuteCustom={handleExecuteCustom}
isExecuting={sendCommandMutation.isPending}
rooms={roomData}
onRowClick={(row) => setDetailPanelCommand(row)}
scrollable={true}
maxHeight="500px"
enablePagination
defaultPageSize={10}
/>
{/* Detail Dialog Popup */}
<Dialog open={!!detailPanelCommand} onOpenChange={(open) => !open && setDetailPanelCommand(null)}>
<DialogContent className="max-w-2xl max-h-[85vh]">
<DialogHeader>
<DialogTitle>Chi tiết lệnh</DialogTitle>
</DialogHeader>
{detailPanelCommand && (
<div className="space-y-6 max-h-[calc(85vh-120px)] overflow-y-auto pr-2">
{/* Command Name */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tên lệnh</h3>
<p className="text-base font-medium break-words">{detailPanelCommand.commandName}</p>
</div>
{/* Command Type */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Loại lệnh</h3>
<p className="text-base">
{
{
1: "RESTART",
2: "SHUTDOWN",
3: "TASKKILL",
4: "BLOCK",
}[detailPanelCommand.commandType] || "UNKNOWN"
}
</p>
</div>
{/* Description */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2"> tả</h3>
<p className="text-sm text-foreground whitespace-pre-wrap break-words">
{detailPanelCommand.description || "-"}
</p>
</div>
{/* Command Content */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Nội dung lệnh</h3>
<div className="bg-muted/50 p-4 rounded-md border">
<code className="text-sm whitespace-pre-wrap break-all block font-mono">
{detailPanelCommand.commandContent}
</code>
</div>
</div>
{/* QoS */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">QoS</h3>
<p className="text-base">
<span
className={
{
0: "text-blue-600",
1: "text-amber-600",
2: "text-red-600",
}[(detailPanelCommand.qoS ?? 0) as 0 | 1 | 2]
}
>
{detailPanelCommand.qoS ?? 0}
</span>
</p>
</div>
{/* Retention */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Lưu trữ</h3>
<div className="flex items-center gap-2">
{detailPanelCommand.isRetained ? (
<>
<Check className="h-4 w-4 text-green-600" />
<span className="text-sm text-green-600"></span>
</>
) : (
<>
<X className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-400">Không</span>
</>
)}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,15 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Dashboard", path: "/_auth/dashboard/" },
];
},
})
function RouteComponent() {
return <div>Hello "/(auth)/dashboard/"!</div>
}

View File

@ -0,0 +1,125 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
formOptions,
useForm,
} from '@tanstack/react-form'
interface LoginFormProps {
username: string
password: string
}
const defaultInput: LoginFormProps = {
username: '',
password: '',
}
const formOpts = formOptions({
defaultValues: defaultInput,
})
export const Route = createFileRoute('/_auth/login/')({
beforeLoad: async ({ context }) => {
const { authToken } = context.auth
if (authToken) throw redirect({ to: '/' })
},
component: LoginForm,
})
function LoginForm() {
const form = useForm({
...formOpts,
onSubmit: async ({ value }) => {
console.log('Submitting login form with values:', value)
// Giả lập đăng nhập
if (value.username === 'admin' && value.password === '123456') {
alert('Đăng nhập thành công!')
// Thêm xử lý lưu token, redirect...
} else {
alert('Tài khoản hoặc mật khẩu không đúng.')
}
},
})
return (
<Card className="max-w-md mx-auto mt-20 p-6">
<CardHeader>
<CardTitle>Đăng nhập</CardTitle>
<CardDescription>
Vui lòng nhập thông tin đăng nhập của bạn.
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
className="space-y-4"
>
{/* Username */}
<form.Field name="username">
{(field) => (
<div>
<Label htmlFor="username">Tên đăng nhập</Label>
<Input
id="username"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Tên đăng nhập"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
{/* Password */}
<form.Field name="password">
{(field) => (
<div>
<Label htmlFor="password">Mật khẩu</Label>
<Input
id="password"
type="password"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Mật khẩu"
/>
{field.state.meta.isTouched && field.state.meta.errors && (
<p className="text-sm text-red-500 mt-1">
{field.state.meta.errors}
</p>
)}
</div>
)}
</form.Field>
<Button type="submit" className="w-full">
Đăng nhập
</Button>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
Chưa tài khoản? <span className="underline cursor-pointer">Đăng </span>
</p>
</CardFooter>
</Card>
)
}

View File

@ -1,89 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { User, Key, Shield } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
export const Route = createFileRoute("/_auth/profile/$userName/")({
component: UserProfileComponent,
loader: async ({ context, params }) => {
const { userName } = params as unknown as { userName: string };
context.breadcrumbs = [
{ title: "Tài khoản", path: "#" },
{ title: "Thông tin cá nhân", path: `/profile/${userName}` },
];
},
});
function UserProfileComponent() {
const navigate = useNavigate();
const auth = useAuth();
const { userName } = Route.useParams() as { userName: string };
// Only allow viewing own profile
const isOwnProfile = auth.username === userName;
if (!isOwnProfile) {
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col items-center justify-center py-12 gap-4">
<p className="text-muted-foreground">Bạn không quyền xem hồ này</p>
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
Quay lại
</Button>
</div>
</div>
);
}
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col gap-6">
{/* Avatar Section */}
<div className="flex justify-center">
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center border">
<User className="h-12 w-12 text-muted-foreground" />
</div>
</div>
{/* Info Section */}
<div className="space-y-0">
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Tên đăng nhập</span>
<span className="font-medium">{auth.username}</span>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Họ tên</span>
<span className="font-medium">{auth.name || "Chưa cập nhật"}</span>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Vai trò</span>
<Badge variant="outline" className="flex items-center gap-1">
<Shield className="h-3 w-3" />
{auth.role.roleName || "Chưa cập nhật"}
</Badge>
</div>
<div className="flex justify-between items-center py-4 border-b">
<span className="text-muted-foreground">Cấp đ ưu tiên</span>
<span className="font-medium">{auth.role.priority}</span>
</div>
</div>
{/* Action Button */}
<div className="pt-2">
<Button
variant="outline"
className="w-full"
onClick={() => navigate({ to: "/profile/change-password" as any })}
>
<Key className="h-4 w-4 mr-2" />
Đi mật khẩu
</Button>
</div>
</div>
</div>
);
}

View File

@ -1,157 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useChangePassword } from "@/hooks/queries";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/profile/change-password/")({
component: SelfChangePasswordComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Tài khoản", path: "#" },
{ title: "Đổi mật khẩu", path: "/profile/change-password" },
];
},
});
function SelfChangePasswordComponent() {
const navigate = useNavigate();
const mutation = useChangePassword();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const validateForm = () => {
if (!currentPassword) {
setError("Mật khẩu hiện tại là bắt buộc");
return false;
}
if (!newPassword) {
setError("Mật khẩu mới là bắt buộc");
return false;
}
if (newPassword.length < 6) {
setError("Mật khẩu phải có ít nhất 6 ký tự");
return false;
}
if (!confirmPassword) {
setError("Xác nhận mật khẩu là bắt buộc");
return false;
}
if (newPassword !== confirmPassword) {
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
return false;
}
if (currentPassword === newPassword) {
setError("Mật khẩu mới không được trùng với mật khẩu hiện tại");
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!validateForm()) return;
mutation.mutate(
{ currentPassword: currentPassword, newPassword: newPassword },
{
onSuccess: () => {
toast.success("Đổi mật khẩu thành công");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
mutation.reset();
},
onError: () => {
toast.error("Đổi mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
},
}
);
};
const handleCancel = () => {
navigate({ to: "/dashboard" });
};
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="currentPassword">Mật khẩu hiện tại</Label>
<Input
id="currentPassword"
type="password"
placeholder="Nhập mật khẩu hiện tại"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">Mật khẩu mới</Label>
<Input
id="newPassword"
type="password"
placeholder="Nhập mật khẩu mới"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Nhập lại mật khẩu mới"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{mutation.isError && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>
lỗi xảy ra, vui lòng thử lại
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Đang lưu....
</>
) : (
"Cập nhật"
)}
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Hủy
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,311 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import {
useGetRoleById,
useGetRolePermissions,
useGetPermissionList,
useToggleRolePermission,
useAssignRolePermissions,
} from "@/hooks/queries";
import { useState, useEffect, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Shield, ArrowLeft, Save, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import type { Permission, PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/role/$id/edit/")({
component: EditRolePermissionsComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý role", path: "/role" },
{ title: "Chỉnh sửa quyền", path: `/role/${params.id}/edit` },
];
},
});
function EditRolePermissionsComponent() {
const navigate = useNavigate();
const { id } = Route.useParams();
const roleId = Number(id);
// Queries
const { data: role, isLoading: roleLoading } = useGetRoleById(roleId);
const { data: rolePermissions = [], isLoading: rolePermissionsLoading } = useGetRolePermissions(roleId);
const { data: allPermissions = [], isLoading: permissionsLoading } = useGetPermissionList();
// Mutations
const toggleMutation = useToggleRolePermission();
const assignMutation = useAssignRolePermissions();
// State
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Initialize selected permissions from role's current permissions
useEffect(() => {
if (rolePermissions && Array.isArray(rolePermissions)) {
// Use permissionEnum as the identifier (matches value from permission list)
const checkedPermissions = rolePermissions
.filter((p: PermissionOnRole) => p.isChecked === 1)
.map((p: PermissionOnRole) => p.permissionEnum);
setSelectedPermissions(checkedPermissions);
}
}, [rolePermissions]);
// Group permissions by parent (category)
const groupedPermissions = useMemo(() => {
const groups: Record<string, Permission[]> = {};
const permissionList = Array.isArray(allPermissions) ? allPermissions : [];
// First pass: identify all parent categories
const parentPermissions: Permission[] = [];
const childPermissions: Permission[] = [];
permissionList.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const isParent = permValue % 10 === 0;
if (isParent) {
parentPermissions.push(perm);
groups[perm.name] = [];
} else {
childPermissions.push(perm);
}
});
// Second pass: assign children to parent categories
childPermissions.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const parentEnum = Math.floor(permValue / 10) * 10;
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
const parentName = parent?.name || "Khác";
if (!groups[parentName]) {
groups[parentName] = [];
}
groups[parentName].push(perm);
});
// Third pass: add parent permissions that have no children as selectable items
parentPermissions.forEach((parent) => {
const parentValue = parent.value ?? parent.enum ?? 0;
const hasChildren = childPermissions.some((child) => {
const childValue = child.value ?? child.enum ?? 0;
return Math.floor(childValue / 10) * 10 === parentValue;
});
if (!hasChildren) {
groups[parent.name].push(parent);
}
});
// Remove empty groups
Object.keys(groups).forEach((key) => {
if (groups[key].length === 0) {
delete groups[key];
}
});
return groups;
}, [allPermissions]);
// Helper to get unique identifier for permission (use value as ID)
const getPermId = (perm: Permission) => perm.value ?? perm.id ?? 0;
const handleTogglePermission = (permissionValue: number) => {
setSelectedPermissions((prev) =>
prev.includes(permissionValue)
? prev.filter((v) => v !== permissionValue)
: [...prev, permissionValue]
);
setHasChanges(true);
};
const handleSelectAll = (categoryPermissions: Permission[]) => {
const allValues = categoryPermissions.map((p) => getPermId(p));
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
if (allSelected) {
setSelectedPermissions((prev) =>
prev.filter((v) => !allValues.includes(v))
);
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
}
setHasChanges(true);
};
// Save all permissions at once
const handleSaveAll = async () => {
try {
await assignMutation.mutateAsync({
roleId,
permissionIds: selectedPermissions,
});
toast.success("Cập nhật quyền thành công!");
setHasChanges(false);
} catch (error) {
toast.error("Cập nhật quyền thất bại!");
}
};
const isLoading = roleLoading || rolePermissionsLoading || permissionsLoading;
if (isLoading) {
return (
<div className="w-full px-6 flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
Chỉnh sửa quyền:
<Badge variant="secondary" className="text-lg">
{role?.roleName}
</Badge>
</h1>
<p className="text-muted-foreground mt-2">
Quản quyền hạn của role này
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
{hasChanges && (
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{assignMutation.isPending ? "Đang lưu..." : "Lưu thay đổi"}
</Button>
)}
</div>
</div>
{/* Role Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" /> Thông tin Role
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<span className="text-sm text-muted-foreground">Tên Role</span>
<p className="font-medium">{role?.roleName}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Đ ưu tiên</span>
<p className="font-medium">{role?.priority}</p>
</div>
<div>
<span className="text-sm text-muted-foreground">Số quyền đã gán</span>
<p className="font-medium">{selectedPermissions.length}</p>
</div>
</div>
</CardContent>
</Card>
{/* Permissions */}
<Card>
<CardHeader>
<CardTitle>Quyền hạn</CardTitle>
<CardDescription>
Tick chọn đ bật/tắt quyền ({selectedPermissions.length} đang đưc gán)
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[500px] pr-4">
<div className="space-y-6">
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
{category}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelectAll(perms)}
>
{perms.every((p) => selectedPermissions.includes(getPermId(p)))
? "Bỏ tất cả"
: "Chọn tất cả"}
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{perms.map((perm) => {
const permValue = getPermId(perm);
const isChecked = selectedPermissions.includes(permValue);
return (
<div
key={permValue}
className={`flex items-center space-x-2 p-2 rounded border hover:bg-muted/50 transition-colors ${
isChecked ? "bg-primary/5 border-primary/30" : ""
}`}
>
<Checkbox
id={`perm-${permValue}`}
checked={isChecked}
onCheckedChange={() => handleTogglePermission(permValue)}
disabled={toggleMutation.isPending}
/>
<label
htmlFor={`perm-${permValue}`}
className="text-sm cursor-pointer flex-1"
>
{perm.name}
<span className="text-xs text-muted-foreground ml-1">
({permValue})
</span>
</label>
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Footer Actions */}
{hasChanges && (
<div className="flex justify-end gap-2 sticky bottom-4 bg-background p-4 rounded-lg border shadow-lg">
<Button
variant="outline"
onClick={() => {
// Reset to original - use permissionEnum as identifier
const checkedPermissions = (rolePermissions as PermissionOnRole[])
.filter((p) => p.isChecked === 1)
.map((p) => p.permissionEnum);
setSelectedPermissions(checkedPermissions);
setHasChanges(false);
}}
>
Hủy thay đi
</Button>
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{assignMutation.isPending ? "Đang lưu..." : "Lưu tất cả thay đổi"}
</Button>
</div>
)}
</div>
);
}

View File

@ -1,273 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetPermissionList, useCreateRole } from "@/hooks/queries";
import { useState, useMemo } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Shield, ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { Permission } from "@/types/permission";
export const Route = createFileRoute("/_auth/role/create/")({
component: CreateRoleComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý role", path: "/role" },
{ title: "Tạo role mới", path: "/role/create" },
];
},
});
function CreateRoleComponent() {
const navigate = useNavigate();
const { data: permissions = [], isLoading: permissionsLoading } = useGetPermissionList();
const createMutation = useCreateRole();
const [roleName, setRoleName] = useState("");
const [priority, setPriority] = useState(0);
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
// Helper to get unique identifier for permission
const getPermValue = (perm: Permission) => perm.value ?? perm.id ?? 0;
// Group permissions by parent (category)
const groupedPermissions = useMemo(() => {
const groups: Record<string, Permission[]> = {};
const permissionList = Array.isArray(permissions) ? permissions : [];
// First pass: identify all parent categories
const parentPermissions: Permission[] = [];
const childPermissions: Permission[] = [];
permissionList.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const isParent = permValue % 10 === 0;
if (isParent) {
parentPermissions.push(perm);
groups[perm.name] = [];
} else {
childPermissions.push(perm);
}
});
// Second pass: assign children to parent categories
childPermissions.forEach((perm: Permission) => {
const permValue = perm.value ?? perm.enum ?? 0;
const parentEnum = Math.floor(permValue / 10) * 10;
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
const parentName = parent?.name || "Khác";
if (!groups[parentName]) {
groups[parentName] = [];
}
groups[parentName].push(perm);
});
// Third pass: add parent permissions that have no children as selectable items
// (like ALLOW_ALL which is value 0 with no children)
parentPermissions.forEach((parent) => {
const parentValue = parent.value ?? parent.enum ?? 0;
// Check if this parent has any children
const hasChildren = childPermissions.some((child) => {
const childValue = child.value ?? child.enum ?? 0;
return Math.floor(childValue / 10) * 10 === parentValue;
});
// If no children, add the parent itself as a selectable item
if (!hasChildren) {
groups[parent.name].push(parent);
}
});
// Remove empty groups
Object.keys(groups).forEach((key) => {
if (groups[key].length === 0) {
delete groups[key];
}
});
return groups;
}, [permissions]);
const handleTogglePermission = (permissionValue: number) => {
setSelectedPermissions((prev) =>
prev.includes(permissionValue)
? prev.filter((v) => v !== permissionValue)
: [...prev, permissionValue]
);
};
const handleSelectAll = (categoryPermissions: Permission[]) => {
const allValues = categoryPermissions.map((p) => getPermValue(p));
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
if (allSelected) {
setSelectedPermissions((prev) =>
prev.filter((v) => !allValues.includes(v))
);
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!roleName.trim()) {
toast.error("Vui lòng nhập tên role!");
return;
}
try {
await createMutation.mutateAsync({
RoleName: roleName,
Priority: priority,
PermissionIds: selectedPermissions,
});
toast.success("Tạo role thành công!");
navigate({ to: "/role" });
} catch (error) {
toast.error("Tạo role thất bại!");
}
};
return (
<div className="w-full px-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Tạo Role mới</h1>
<p className="text-muted-foreground mt-2">
Tạo vai trò mới gán quyền hạn
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Role Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" /> Thông tin Role
</CardTitle>
<CardDescription>
Nhập thông tin bản của role
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="roleName">Tên Role *</Label>
<Input
id="roleName"
value={roleName}
onChange={(e) => setRoleName(e.target.value)}
placeholder="Nhập tên role..."
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="priority">Đ ưu tiên</Label>
<Input
id="priority"
type="number"
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
placeholder="0"
/>
</div>
</div>
</CardContent>
</Card>
{/* Permissions Selection */}
<Card>
<CardHeader>
<CardTitle>Chọn quyền hạn</CardTitle>
<CardDescription>
Chọn các quyền role này đưc phép thực hiện ({selectedPermissions.length} đã chọn)
</CardDescription>
</CardHeader>
<CardContent>
{permissionsLoading ? (
<div className="text-center py-4">Đang tải danh sách quyền...</div>
) : (
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-6">
{Object.entries(groupedPermissions).map(([category, perms]) => (
<div key={category} className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
{category}
</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelectAll(perms)}
>
{perms.every((p) => selectedPermissions.includes(getPermValue(p)))
? "Bỏ tất cả"
: "Chọn tất cả"}
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{perms.map((perm) => {
const permValue = getPermValue(perm);
return (
<div
key={permValue}
className="flex items-center space-x-2 p-2 rounded border hover:bg-muted/50"
>
<Checkbox
id={`perm-${permValue}`}
checked={selectedPermissions.includes(permValue)}
onCheckedChange={() => handleTogglePermission(permValue)}
/>
<Label
htmlFor={`perm-${permValue}`}
className="text-sm cursor-pointer flex-1"
>
{perm.name}
</Label>
</div>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
{/* Submit */}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: "/role" })}
>
Hủy
</Button>
<Button type="submit" disabled={createMutation.isPending}>
<Save className="h-4 w-4 mr-2" />
{createMutation.isPending ? "Đang tạo..." : "Tạo Role"}
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,134 +0,0 @@
import { useGetRoleList, useDeleteRole } from "@/hooks/queries";
import { formatDate } from "@/lib/utils";
import type { TRoleResponse } from "@/types/role";
import { createFileRoute, Link } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import { RoleManagerTemplate } from "@/template/role-manager-template";
import { Button } from "@/components/ui/button";
import { Pencil, Trash2, Shield } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/role/")({
component: RoleComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{
title: "Quản lý role",
path: "#",
},
{
title: "Danh sách role",
path: "/role",
},
];
},
});
function RoleComponent() {
const { data: roles = [], isLoading } = useGetRoleList();
const roleList = Array.isArray(roles) ? roles : [roles];
const deleteMutation = useDeleteRole();
const handleDelete = async (id: number, roleName: string) => {
if (window.confirm(`Bạn có chắc chắn muốn xóa role "${roleName}"?`)) {
try {
await deleteMutation.mutateAsync(id);
toast.success("Xóa role thành công!");
} catch (error) {
toast.error("Xóa role thất bại!");
}
}
};
const columns: ColumnDef<TRoleResponse>[] = [
{
accessorKey: "roleName",
header: () => <div className="font-bold text-center">Role</div>,
cell: ({ row }) => (
<div className="text-center font-medium">{row.original.roleName}</div>
),
},
{
accessorKey: "priority",
header: () => <div className="font-bold text-center">Đ ưu tiên</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.priority}</div>
),
},
{
accessorKey: "createdAt",
header: () => <div className="font-bold text-center">Ngày tạo</div>,
cell: ({ row }) => (
<div className="text-center">
{formatDate(row.original.createdAt)}
</div>
),
},
{
accessorKey: "createdBy",
header: () => <div className="font-bold text-center">Người tạo</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.createdBy || "-"}</div>
),
},
{
accessorKey: "updatedAt",
header: () => <div className="font-bold text-center">Ngày cập nhật</div>,
cell: ({ row }) => (
<div className="text-center">
{formatDate(row.original.updatedAt)}
</div>
),
},
{
accessorKey: "updatedBy",
header: () => <div className="font-bold text-center">Người cập nhật</div>,
cell: ({ row }) => (
<div className="text-center">{row.original.updatedBy || "-"}</div>
),
},
{
id: "actions",
header: () => <div className="font-bold text-center">Hành đng</div>,
cell: ({ row }) => (
<div className="flex items-center justify-center gap-2">
<Link
to="/role/$id/edit"
params={{ id: String(row.original.id) }}
>
<Button variant="outline" size="sm">
<Pencil className="h-4 w-4 mr-1" />
Sửa quyền
</Button>
</Link>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(row.original.id, row.original.roleName)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4 mr-1" />
Xóa
</Button>
</div>
),
},
];
return (
<RoleManagerTemplate<TRoleResponse>
title="Quản lý Role"
description="Quản lý các vai trò và quyền hạn trong hệ thống"
data={roleList}
isLoading={isLoading}
columns={columns}
icon={Shield}
tableTitle="Danh sách Role"
tableDescription="Các vai trò trong hệ thống và quyền hạn tương ứng"
createButtonLabel="Tạo role mới"
createLink="/role/create"
enablePagination
defaultPageSize={10}
/>
);
}

View File

@ -1,158 +0,0 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { useClientFolderStatus } from "@/hooks/useClientFolderStatus";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { toast } from "sonner";
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
component: RoomDetailPage,
});
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const [isCheckingFolder, setIsCheckingFolder] = useState(false);
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SSE
const folderStatuses = useClientFolderStatus(roomName);
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const parseMachineNumber = useMachineNumber();
const handleCheckFolderStatus = async () => {
try {
setIsCheckingFolder(true);
// Trigger folder status check via the service
const response = await fetch(
`/api/device-comm/request-get-client-folder-status?roomName=${encodeURIComponent(roomName)}`,
{
method: "POST",
}
);
if (!response.ok) {
throw new Error("Failed to request folder status");
}
toast.success("Đang kiểm tra thư mục Setup...");
} catch (error) {
console.error("Check folder error:", error);
toast.error("Lỗi khi kiểm tra thư mục!");
} finally {
setIsCheckingFolder(false);
}
};
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-4">
{/* Hàng 1: Thông tin phòng và controls */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</div>
{/* Hàng 2: Thực thi lệnh */}
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2 text-sm font-semibold">
Thực thi lệnh
</div>
<div className="flex items-center gap-3 flex-wrap justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<>
<CommandActionButtons roomName={roomName} />
<div className="h-8 w-px bg-border" />
<Button
onClick={handleCheckFolderStatus}
disabled={isCheckingFolder}
variant="outline"
size="sm"
className="flex items-center gap-2 shrink-0"
>
{isCheckingFolder ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FolderCheck className="h-4 w-4" />
)}
{isCheckingFolder ? "Đang kiểm tra..." : "Kiểm tra thư mục Setup"}
</Button>
</>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
) : (
<DeviceTable
devices={sortedDevices}
folderStatuses={folderStatuses}
isCheckingFolder={isCheckingFolder}
/>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,140 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useChangePasswordAdmin } from "@/hooks/queries";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
component: AdminChangePasswordComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "/user" },
{
title: `Thay đổi mật khẩu của người dùng ${params.userName}`,
path: `/user/change-password/${params.userName}`,
},
];
},
});
function AdminChangePasswordComponent() {
const { userName } = Route.useParams();
const navigate = useNavigate();
const mutation = useChangePasswordAdmin();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const validateForm = () => {
if (!newPassword) {
setError("Mật khẩu mới là bắt buộc");
return false;
}
if (newPassword.length < 6) {
setError("Mật khẩu phải có ít nhất 6 ký tự");
return false;
}
if (!confirmPassword) {
setError("Xác nhận mật khẩu là bắt buộc");
return false;
}
if (newPassword !== confirmPassword) {
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
return false;
}
return true;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!validateForm()) return;
mutation.mutate(
{ username: userName, newPassword: newPassword },
{
onSuccess: () => {
toast.success("Cập nhật mật khẩu thành công");
setNewPassword("");
setConfirmPassword("");
mutation.reset();
},
onError: () => {
toast.error("Cập nhật mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
},
}
);
};
const handleCancel = () => {
navigate({ to: "/dashboard" });
};
return (
<div className="container w-2/3 mx-auto">
<div className="flex flex-col gap-4">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="newPassword">Mật khẩu mới</Label>
<Input
id="newPassword"
type="password"
placeholder="Nhập mật khẩu mới"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Nhập lại mật khẩu mới"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{mutation.isError && (
<Alert variant="destructive">
<AlertTitle>Lỗi</AlertTitle>
<AlertDescription>
lỗi xảy ra, vui lòng thử lại
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? (
<>
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
Đang lưu....
</>
) : (
"Cập nhật"
)}
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Hủy
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,312 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetRoleList, useCreateAccount } from "@/hooks/queries";
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/create/")({
component: CreateUserComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" },
{ title: "Tạo người dùng mới", path: "/user/create" },
];
},
});
function CreateUserComponent() {
const navigate = useNavigate();
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
const createMutation = useCreateAccount();
const [formData, setFormData] = useState({
userName: "",
name: "",
password: "",
confirmPassword: "",
roleId: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Validate username theo regex backend
const validateUserName = (userName: string): boolean => {
const regex = /^[a-zA-Z0-9_.]{3,20}$/;
return regex.test(userName);
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
// Validate username
if (!formData.userName) {
newErrors.userName = "Tên đăng nhập không được để trống";
} else if (!validateUserName(formData.userName)) {
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
}
// Validate name
if (!formData.name.trim()) {
newErrors.name = "Họ và tên không được để trống";
}
// Validate password
if (!formData.password) {
newErrors.password = "Mật khẩu không được để trống";
} else if (formData.password.length < 6) {
newErrors.password = "Mật khẩu phải có ít nhất 6 ký tự";
}
// Validate confirm password
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "Mật khẩu xác nhận không khớp";
}
// Validate roleId
if (!formData.roleId) {
newErrors.roleId = "Vui lòng chọn vai trò";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
try {
await createMutation.mutateAsync({
userName: formData.userName,
name: formData.name,
password: formData.password,
roleId: Number(formData.roleId),
accessRooms: [0], // Default value, will be updated when Room API provides IDs
});
toast.success("Tạo tài khoản thành công!");
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
} catch (error: any) {
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage);
}
};
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[field];
return newErrors;
});
}
};
return (
<div className="w-full px-6 py-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
<p className="text-muted-foreground mt-1">
Thêm tài khoản người dùng mới vào hệ thống
</p>
</div>
<Button
variant="outline"
onClick={() => navigate({ to: "/dashboard" })}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="w-full">
<Card className="shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Thông tin tài khoản
</CardTitle>
<CardDescription>
Điền thông tin đ tạo tài khoản người dùng mới
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
{/* Username and Name - Grid Layout */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="userName">
Tên đăng nhập <span className="text-destructive">*</span>
</Label>
<Input
id="userName"
value={formData.userName}
onChange={(e) => handleInputChange("userName", e.target.value)}
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.userName && (
<p className="text-sm text-destructive">{errors.userName}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="name">
Họ tên <span className="text-destructive">*</span>
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="Nhập họ và tên đầy đủ"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
</div>
{/* Password */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password">
Mật khẩu <span className="text-destructive">*</span>
</Label>
<Input
id="password"
type="password"
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.password && (
<p className="text-sm text-destructive">{errors.password}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
Xác nhận mật khẩu <span className="text-destructive">*</span>
</Label>
<Input
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
placeholder="Nhập lại mật khẩu"
disabled={createMutation.isPending}
className="h-10"
/>
{errors.confirmPassword && (
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
)}
</div>
</div>
{/* Role Selection */}
<div className="space-y-2">
<Label htmlFor="roleId">
Vai trò <span className="text-destructive">*</span>
</Label>
{rolesLoading ? (
<div className="flex items-center justify-center p-4 border rounded-md bg-muted/50">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<Select
value={formData.roleId}
onValueChange={(value) => handleInputChange("roleId", value)}
disabled={createMutation.isPending}
>
<SelectTrigger className="h-10">
<SelectValue placeholder="Chọn vai trò cho người dùng" />
</SelectTrigger>
<SelectContent>
{Array.isArray(roles) &&
roles.map((role: any) => (
<SelectItem key={role.id} value={String(role.id)}>
{role.roleName} (Priority: {role.priority})
</SelectItem>
))}
</SelectContent>
</Select>
)}
{errors.roleId && (
<p className="text-sm text-destructive">{errors.roleId}</p>
)}
</div>
{/* TODO: Add Room/Building Access selection when API is ready */}
{/*
<div className="space-y-2">
<Label>Quyền truy cập phòng (Tùy chọn)</Label>
<p className="text-sm text-muted-foreground">
Chọn các phòng người dùng quyền truy cập
</p>
// Add multi-select component here when Room API provides IDs
</div>
*/}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={() => navigate({ to: "/dashboard" })}
disabled={createMutation.isPending}
className="min-w-[100px]"
>
Hủy
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className="min-w-[140px]"
>
{createMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Đang tạo...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Tạo tài khoản
</>
)}
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,132 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetRoleById, useGetRolePermissions } from "@/hooks/queries";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Shield, ArrowLeft, Check, X } from "lucide-react";
import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" },
{ title: `Quyền của Role #${params.roleId}`, path: `/user/role/${params.roleId}` },
];
},
});
function ViewRolePermissionsComponent() {
const { roleId } = Route.useParams();
const navigate = useNavigate();
const roleIdNum = parseInt(roleId, 10);
const { data: role, isLoading: roleLoading } = useGetRoleById(roleIdNum);
const { data: permissions = [], isLoading: permissionsLoading } = useGetRolePermissions(roleIdNum);
const isLoading = roleLoading || permissionsLoading;
// Group permissions by parent
const groupedPermissions = (permissions as PermissionOnRole[]).reduce((acc, permission) => {
if (permission.parentId === null) {
if (!acc[permission.permisionId]) {
acc[permission.permisionId] = { parent: permission, children: [] };
} else {
acc[permission.permisionId].parent = permission;
}
} else {
if (!acc[permission.parentId]) {
acc[permission.parentId] = { parent: null as any, children: [] };
}
acc[permission.parentId].children.push(permission);
}
return acc;
}, {} as Record<number, { parent: PermissionOnRole; children: PermissionOnRole[] }>);
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Quyền hạn của Role: {role?.roleName || `#${roleId}`}
</CardTitle>
<CardDescription>
Danh sách các quyền đưc gán cho role này
{role?.priority !== undefined && (
<span className="ml-2">(Đ ưu tiên: <Badge variant="outline">{role.priority}</Badge>)</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
{permissions.length === 0 ? (
<div className="text-center text-muted-foreground py-8">Không quyền nào đưc gán cho role này</div>
) : (
<div className="space-y-6">
{Object.values(groupedPermissions).map(({ parent, children }) => (
<div key={parent?.permisionId} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="font-semibold text-lg">{parent?.permissionName || "Unknown"}</span>
<Badge variant="secondary" className="text-xs">{parent?.permissionCode}</Badge>
</div>
<div className="flex items-center gap-1">
{parent?.isChecked === 1 ? (
<Badge variant="default" className="bg-green-600">
<Check className="h-3 w-3 mr-1" />Đã bật
</Badge>
) : (
<Badge variant="secondary">
<X className="h-3 w-3 mr-1" />Đã tắt
</Badge>
)}
</div>
</div>
{children.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 mt-3 pl-4 border-l-2 border-muted">
{children.map((child) => (
<div key={child.permisionId} className="flex items-center justify-between p-2 rounded bg-muted/50">
<div className="flex flex-col">
<span className="text-sm font-medium">{child.permissionName}</span>
<span className="text-xs text-muted-foreground">{child.permissionCode}</span>
</div>
{child.isChecked === 1 ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-muted-foreground" />
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,133 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useGetRoleList } from "@/hooks/queries";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Shield, Eye, ArrowLeft, Loader2 } from "lucide-react";
export const Route = createFileRoute("/_auth/user/role/")({
component: RoleListComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "#" },
{ title: "Danh sách vai trò", path: "/user/role" },
];
},
});
function RoleListComponent() {
const navigate = useNavigate();
const { data: roles, isLoading, isError } = useGetRoleList();
if (isLoading) {
return (
<div className="w-full px-6 flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (isError) {
return (
<div className="w-full px-6 space-y-4">
<Card className="max-w-2xl mx-auto">
<CardContent className="pt-6">
<div className="text-center text-destructive">
Không thể tải danh sách vai trò
</div>
<div className="flex justify-center mt-4">
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="w-full px-6 space-y-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate({ to: "/dashboard" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Danh sách vai trò
</CardTitle>
<CardDescription>
Quản các vai trò quyền hạn trong hệ thống
</CardDescription>
</CardHeader>
<CardContent>
{roles && roles.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Tên vai trò</TableHead>
<TableHead>Đ ưu tiên</TableHead>
<TableHead>Ngày tạo</TableHead>
<TableHead className="text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roles.map((role) => (
<TableRow key={role.id}>
<TableCell>
<Badge variant="outline">{role.id}</Badge>
</TableCell>
<TableCell className="font-medium">{role.roleName}</TableCell>
<TableCell>
<Badge variant="secondary">{role.priority}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{role.createdAt ? new Date(role.createdAt).toLocaleDateString("vi-VN") : "—"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
navigate({ to: "/user/role/$roleId", params: { roleId: String(role.id) } } as any)
}
>
<Eye className="h-4 w-4 mr-1" />
Xem quyền
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
Không vai trò nào trong hệ thống
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,21 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import AppLayout from '@/layouts/app-layout'
export const Route = createFileRoute('/_authenticated')({
// Kiểm tra auth trước khi render
// beforeLoad: async ({context}) => {
// const {authToken} = context.auth
// if (!authToken) {
// throw redirect({to: '/login'})
// }
// },
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
return (
<AppLayout>
<Outlet />
</AppLayout>
)
}

View File

@ -1,34 +1,44 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import {
useGetAgentVersion,
useGetRoomList,
useUploadSoftware,
useUpdateAgent,
} from "@/hooks/queries";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import type { AxiosProgressEvent } from "axios";
import type { Version } from "@/types/file";
import { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
import { type Room } from "@/types/room";
export const Route = createFileRoute("/_auth/agent/")({
type Version = {
id?: string;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
export const Route = createFileRoute("/_authenticated/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
errorComponent: ErrorFetchingPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Quản lý Agent", path: "/_auth/agent/" },
];
},
});
function AgentsPage() {
// Lấy danh sách version
const { data, isLoading } = useGetAgentVersion();
const { data, isLoading } = useQueryData({
queryKey: ["agent-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
// Lấy danh sách phòng
const { data: roomData } = useGetRoomList();
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
@ -36,38 +46,48 @@ function AgentsPage() {
? [data]
: [];
const uploadMutation = useUploadSoftware();
// Mutation upload
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["agent-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
const updateMutation = useUpdateAgent();
const updateMutation = useMutationData<void>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu update!"),
onError: () => toast.error("Gửi yêu cầu thất bại!"),
});
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void },
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
try {
await uploadMutation.mutateAsync({
formData: fd,
onUploadProgress: config?.onUploadProgress,
return uploadMutation.mutateAsync({
data: fd,
config,
});
toast.success("Upload thành công!");
} catch (error: any) {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
}
};
const handleUpdate = async (roomNames: string[]) => {
try {
for (const roomName of roomNames) {
try {
await updateMutation.mutateAsync({
roomName,
data: {},
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT(roomName),
method: "POST",
data: undefined
});
} catch {
toast.error(`Gửi yêu cầu thất bại cho ${roomName}`);
}
}
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
} catch (e) {
toast.error("Có lỗi xảy ra khi cập nhật!");
}
};
// Cột bảng
@ -103,9 +123,7 @@ function AgentsPage() {
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}
rooms={roomData}
enablePagination
defaultPageSize={10}
rooms={rooms}
/>
);
}

View File

@ -0,0 +1,154 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppManagerTemplate } from "@/template/app-manager-template";
import { useQueryData } from "@/hooks/useQueryData";
import { useMutationData } from "@/hooks/useMutationData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
import type { AxiosProgressEvent } from "axios";
import type { Room } from "@/types/room";
export const Route = createFileRoute("/_authenticated/apps/")({
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
component: AppsComponent,
});
type Version = {
id: number;
version: string;
fileName: string;
folderPath: string;
updatedAt?: string;
requestUpdateAt?: string;
};
function AppsComponent() {
const { data, isLoading } = useQueryData({
queryKey: ["software-version"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
});
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
const versionList: Version[] = Array.isArray(data)
? data
: data
? [data]
: [];
const [table, setTable] = useState<any>();
const uploadMutation = useMutationData<FormData>({
url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
method: "POST",
invalidate: [["software-version"]],
onSuccess: () => toast.success("Upload thành công!"),
onError: (error) => {
console.error("Upload error:", error);
toast.error("Upload thất bại!");
},
});
const installMutation = useMutationData<{ MsiFileIds: number[] }>({
url: "",
method: "POST",
onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
onError: (error) => {
console.error("Install error:", error);
toast.error("Gửi yêu cầu thất bại!");
},
});
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{ accessorKey: "folderPath", header: "Đường dẫn" },
{
accessorKey: "updatedAt",
header: "Thời gian cập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
{
accessorKey: "requestUpdateAt",
header: "Thời gian yêu cầu cài đặt",
},
{
id: "select",
header: () => <span>Thêm vào danh sách yêu cầu</span>,
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
disabled={installMutation.isPending}
/>
),
enableSorting: false,
enableHiding: false,
},
];
// Upload file MSI
const handleUpload = async (
fd: FormData,
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
) => {
return uploadMutation.mutateAsync({
data: fd,
config,
});
};
// Callback khi chọn phòng
const handleInstall = async (roomNames: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
}
const selectedRows = table.getSelectedRowModel().rows;
if (selectedRows.length === 0) {
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
for (const roomName of roomNames) {
await installMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI(roomName),
data: { MsiFileIds },
});
}
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
};
return (
<AppManagerTemplate<Version>
title="Quản lý phần mềm"
description="Quản lý và gửi yêu cầu cài đặt phần mềm MSI"
data={versionList}
isLoading={isLoading}
columns={columns}
onUpload={handleUpload}
onUpdate={handleInstall}
updateLoading={installMutation.isPending}
onTableInit={setTable}
rooms={rooms}
/>
);
}

View File

@ -0,0 +1,66 @@
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { useQueryData } from "@/hooks/useQueryData";
import { createFileRoute } from "@tanstack/react-router";
import type { ColumnDef } from "@tanstack/react-table";
import { useState } from "react";
type Blacklist = {
id: number;
appName: string;
processName: string;
createdAt?: string;
updatedAt?: string;
createdBy?: string;
};
export const Route = createFileRoute("/_authenticated/blacklist/")({
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
component: BlacklistComponent,
});
function BlacklistComponent() {
const { data, isLoading } = useQueryData({
queryKey: ["blacklist"],
url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
});
const blacklist: Blacklist[] = Array.isArray(data)
? (data as Blacklist[])
: [];
const columns : ColumnDef<Blacklist>[] =
[
{
accessorKey: "id",
header: "ID",
cell: info => info.getValue(),
},
{
accessorKey: "appName",
header: "Tên ứng dụng",
cell: info => info.getValue(),
},
{
accessorKey: "processName",
header: "Tên tiến trình",
cell: info => info.getValue(),
},
{
accessorKey: "createdAt",
header: "Ngày tạo",
cell: info => info.getValue(),
},
{
accessorKey: "updatedAt",
header: "Ngày cập nhật",
cell: info => info.getValue(),
},
{
accessorKey: "createdBy",
header: "Người tạo",
cell: info => info.getValue(),
},
]
return <div>Hello "/_authenticated/blacklist/"!</div>;
}

View File

@ -0,0 +1,73 @@
import { createFileRoute } from "@tanstack/react-router";
import { FormSubmitTemplate } from "@/template/form-submit-template";
import { ShellCommandForm } from "@/components/command-form";
import { useMutationData } from "@/hooks/useMutationData";
import { useQueryData } from "@/hooks/useQueryData";
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
import { toast } from "sonner";
import type { Room } from "@/types/room";
type SendCommandRequest = { Command: string };
type SendCommandResponse = { status: string; message: string };
export const Route = createFileRoute("/_authenticated/command/")({
head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
component: CommandPage,
});
function CommandPage() {
// Lấy danh sách phòng từ API
const { data: roomData } = useQueryData({
queryKey: ["rooms"],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST,
});
// Map từ object sang string[]
const rooms: string[] = Array.isArray(roomData)
? (roomData as Room[]).map((r) => r.name)
: [];
// Mutation gửi lệnh
const sendCommandMutation = useMutationData<
SendCommandRequest,
SendCommandResponse
>({
url: "", // sẽ set động theo roomName khi gọi
method: "POST",
onSuccess: (data) => {
if (data.status === "OK") {
toast.success("Gửi lệnh thành công!");
} else {
toast.error("Gửi lệnh thất bại!");
}
},
onError: (error) => {
console.error("Send command error:", error);
toast.error("Gửi lệnh thất bại!");
},
});
return (
<FormSubmitTemplate
title="CMD Command"
description="Gửi lệnh shell xuống thiết bị để thực thi"
isLoading={sendCommandMutation.isPending}
rooms={rooms}
onSubmit={(roomName, command) => {
sendCommandMutation.mutateAsync({
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND(roomName),
data: { Command: command },
});
}}
submitLoading={sendCommandMutation.isPending}
>
{({ command, setCommand }) => (
<ShellCommandForm
command={command}
onCommandChange={setCommand}
disabled={sendCommandMutation.isPending}
/>
)}
</FormSubmitTemplate>
);
}

View File

@ -1,6 +1,6 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/device/')({
export const Route = createFileRoute('/_authenticated/device/')({
head: () => ({ meta: [{ title: 'Danh sách tất cả thiết bị' }] }),
component: AllDevicesComponent,
})

View File

@ -0,0 +1,75 @@
import { createFileRoute, useParams } from "@tanstack/react-router";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useQueryData } from "@/hooks/useQueryData";
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
import { DeviceGrid } from "@/components/device-grid";
import { DeviceTable } from "@/components/device-table";
export const Route = createFileRoute("/_authenticated/room/$roomName/")({
head: ({ params }) => ({
meta: [{ title: `Danh sách thiết bị phòng ${params.roomName}` }],
}),
component: RoomDetailPage,
});
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_authenticated/room/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
const { data: devices = [] } = useQueryData({
queryKey: ["devices", roomName],
url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_DEVICE_FROM_ROOM(roomName),
});
return (
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Monitor className="h-5 w-5" />
Danh sách thiết bị phòng {roomName}
</CardTitle>
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="flex items-center gap-2"
>
<LayoutGrid className="h-4 w-4" />
đ
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="flex items-center gap-2"
>
<TableIcon className="h-4 w-4" />
Bảng
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{devices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Không thiết bị</h3>
<p className="text-muted-foreground text-center max-w-sm">
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : viewMode === "grid" ? (
<DeviceGrid devices={devices} />
) : (
<DeviceTable devices={devices} />
)}
</CardContent>
</Card>
</div>
);
}

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