Compare commits

..

No commits in common. "main" and "feat/meshcentral" have entirely different histories.

85 changed files with 2031 additions and 6259 deletions

View File

@ -1,177 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS

View File

@ -1,42 +0,0 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@ -1,28 +0,0 @@
name: CI
on:
push:
pull_request:
jobs:
build-test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22.14.0
cache: npm
- name: Install deps
run: npm ci
- name: Test
run: npm run test -- --passWithNoTests
- name: Build
run: npm run build

View File

@ -1,45 +0,0 @@
name: Deploy Staging (Docker)
on:
push:
branches: [main]
jobs:
deploy:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Generate build env
env:
VITE_API_MESH: ${{ secrets.VITE_API_MESH }}
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY: ${{ secrets.VITE_ENABLE_MESH_DIRECT_AFTER_PROXY }}
run: |
cat > .env.production <<EOF
VITE_API_MESH=$VITE_API_MESH
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY=$VITE_ENABLE_MESH_DIRECT_AFTER_PROXY
EOF
- name: Build Image
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
docker build --build-arg NODE_VERSION=22.14.0 -t $IMAGE:staging-${{ github.sha }} .
- name: Deploy local (docker compose)
run: |
IMAGE="${{ secrets.IMAGE_NAME }}"
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
COMPOSE_FILE=/home/compmanage/docker-compose.yml
OVERRIDE_FILE=/tmp/ttmt-frontend.override.yml
cat > $OVERRIDE_FILE <<EOF
services:
frontend:
image: $IMAGE:staging-${{ github.sha }}
EOF
docker rm -f ttmt-frontend || true
docker compose -f $COMPOSE_FILE -f $OVERRIDE_FILE up -d --no-deps --force-recreate frontend
rm -f $OVERRIDE_FILE

1
.gitignore vendored
View File

@ -8,4 +8,3 @@ count.txt
.nitro
.tanstack
.vscode/
plans/

View File

@ -20,6 +20,6 @@ COPY --from=development /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 443
EXPOSE 80
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]

341
HOOKS_USAGE_GUIDE.md Normal file
View File

@ -0,0 +1,341 @@
# 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!

1086
INTEGRATION_GUIDE_VI.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,93 +0,0 @@
# Frontend Guide - Microsoft SSO (Entra ID)
## 1) Muc tieu
Tai lieu nay danh cho frontend de tich hop dang nhap Microsoft SSO voi backend TTMT.CompManageWeb.
## 2) Redirect URI da dang ky
Redirect URI cho Microsoft app:
https://comp.soict.io/api/auth/sso/callback
Luu y:
- Redirect URI trong Azure App Registration phai giong 100% (scheme, domain, path).
- Sai ky tu hoac sai slash cuoi co the gay loi redirect_uri mismatch.
## 3) Endpoint frontend can goi
Backend route SSO Microsoft (legacy route):
- GET /api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/auth/sso/exchange
Route alias cung ho tro:
- GET /api/sso/login?returnUrl={FRONTEND_RETURN_URL}
- POST /api/sso/exchange
## 4) Login flow cho frontend
1. User bam nut "Login with Microsoft".
2. Frontend redirect browser den:
/api/auth/sso/login?returnUrl={FRONTEND_RETURN_URL}
3. Backend redirect sang Microsoft login page.
4. Sau khi user xac thuc thanh cong, Microsoft goi ve redirect URI:
https://comp.soict.io/api/auth/sso/callback
5. Backend tao one-time code va redirect user ve FRONTEND_RETURN_URL kem query:
?code={ONE_TIME_CODE}
6. Frontend doc code trong URL, goi API exchange de doi code lay JWT noi bo.
7. Frontend luu token va thong tin user, dieu huong vao app.
## 5) API exchange chi tiet
Method: POST
Path: /api/auth/sso/exchange
Content-Type: application/json
Request body:
{
"code": "<one-time-code>"
}
Success response (HTTP 200):
{
"token": "<jwt>",
"name": "Nguyen Van A",
"username": "user@hust.edu.vn",
"access": [1, 2, 3],
"role": {
"roleName": "Pending",
"priority": 99
}
}
## 6) Error handling goi y cho frontend
- 400 BadRequest: code thieu hoac token khong hop le.
- 401 Unauthorized: code het han / da dung / khong hop le.
- 404 NotFound: user khong ton tai sau khi exchange.
One-time code co han su dung ngan (khoang 2 phut) va chi dung 1 lan.
Neu exchange that bai, can yeu cau user login lai.
## 7) Mau frontend pseudo-code
const returnUrl = window.location.origin + "/sso/complete";
window.location.href = `/api/auth/sso/login?returnUrl=${encodeURIComponent(returnUrl)}`;
// tai /sso/complete
const code = new URLSearchParams(window.location.search).get("code");
if (code) {
const res = await fetch("/api/auth/sso/exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code })
});
if (!res.ok) {
// hien thi thong bao loi va cho user thu lai
} else {
const data = await res.json();
// save data.token, data.username, data.role, data.access
}
}
## 8) Checklist truoc khi UAT
- Redirect URI trong Azure dung: https://comp.soict.io/api/auth/sso/callback
- Frontend returnUrl la URL FE hop le (vi du: https://comp.soict.io/sso/complete)
- Frontend parse duoc query code
- Frontend goi exchange ngay sau khi nhan code
- Frontend xu ly day du HTTP 400/401/404

View File

@ -1,343 +0,0 @@
# Google OAuth (OIDC) + AzureAD Shared SSO Service
Tai lieu nay tong hop toan bo flow dang nhap OAuth/OIDC theo muc coding cho codebase hien tai.
Pham vi:
- Giu nguyen login username/password cu.
- Dung chung 1 service cho AzureAD SSO va Google OAuth OIDC.
- Van tao/cap nhat user noi bo trong DB.
- User moi vao role Pending (khong co permission), doi admin cap quyen.
----------------------------------------
## 1) Trang thai implementation hien tai
Da hoan tat trong code:
- Shared service theo provider key: SsoService.
- Controller da provider: OAuthController.
- Controller AzureAD cu (SsoController) da chay tren shared service.
- Config da provider qua OAuthProviders trong appsettings.
File chinh:
- TTMT.CompManageWeb/Program.cs
- TTMT.CompManageWeb/Interfaces/ISsoService.cs
- TTMT.CompManageWeb/Services/SsoSerivce.cs
- TTMT.CompManageWeb/Controllers/OAuthController.cs
- TTMT.CompManageWeb/Controllers/SsoController.cs
- TTMT.CompManageWeb/Dtos/Auth/OAuthProviderOptions.cs
- TTMT.CompManageWeb/Dtos/Auth/OAuthProvidersOptions.cs
- TTMT.CompManageWeb/appsettings.json
----------------------------------------
## 2) Kien truc luong dang nhap
### 2.1 Luong OAuth Google
1. FE redirect user den:
- GET /api/auth/oauth/google/login?returnUrl=<frontend-url>
2. Backend build authorize URL va redirect qua Google.
3. Google callback ve backend:
- GET /api/auth/oauth/google/callback?code=...&state=...
4. Backend:
- Exchange code -> token endpoint.
- Validate id_token.
- Lay email/name.
- Kiem tra domain.
- Upsert user noi bo.
- Issue JWT noi bo.
- Tao one-time code (2 phut).
- Redirect ve returnUrl?code=<one-time-code>.
5. FE goi API exchange:
- POST /api/auth/oauth/exchange
- Body: { "code": "..." }
6. Backend consume one-time code va tra payload login (token + role + permission).
### 2.2 Luong AzureAD cu
Van giu endpoint cu:
- GET /api/auth/sso/login
- GET /api/auth/sso/callback
- POST /api/auth/sso/exchange
Nhung ben trong da dung chung service theo provider azuread.
----------------------------------------
## 3) Cau hinh bat buoc
### 3.1 appsettings.json
```json
{
"OAuthProviders": {
"DefaultProvider": "azuread",
"Providers": {
"google": {
"Authority": "https://accounts.google.com",
"AuthorizationEndpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"TokenEndpoint": "https://oauth2.googleapis.com/token",
"ClientId": "",
"ClientSecret": "",
"CallbackPath": "/api/auth/oauth/google/callback",
"AllowedDomain": "",
"PendingRoleName": "Pending",
"Scopes": "openid profile email",
"Issuer": "https://accounts.google.com",
"EmailClaim": "email",
"NameClaim": "name",
"HostedDomainClaimName": "hd",
"EnforceHostedDomainClaim": true
}
}
}
}
```
Giai thich nhanh:
- AllowedDomain:
- Rong: cho phep moi domain.
- Co gia tri (vd hust.edu.vn): chi cho email domain nay.
- EnforceHostedDomainClaim=true:
- Neu co claim hd trong token thi bat buoc phai khop AllowedDomain.
- Neu token khong co hd, he thong fallback check theo email domain.
- PendingRoleName: role duoc gan khi user moi dang nhap lan dau.
### 3.2 Google Cloud Console
1. Tao OAuth client (Web application).
2. Authorized redirect URIs:
- https://<your-domain>/api/auth/oauth/google/callback
- (dev) https://localhost:<port>/api/auth/oauth/google/callback
3. Lay ClientId, ClientSecret va dien vao appsettings (hoac secrets).
----------------------------------------
## 4) Dang ky DI va options
Trong Program.cs can co:
```csharp
builder.Services.Configure<AzureAdOptions>(
builder.Configuration.GetSection(AzureAdOptions.SectionName));
builder.Services.Configure<OAuthProvidersOptions>(
builder.Configuration.GetSection(OAuthProvidersOptions.SectionName));
builder.Services.AddScoped<ISsoService, SsoService>();
```
OAuthProvidersOptions dung dictionary provider theo key (vd google, azuread, okta).
----------------------------------------
## 5) Thiet ke interface va service chung
### 5.1 Interface ISsoService
Interface da duoc mo rong thanh provider-aware:
- BuildAuthorizeUrl(provider, redirectUri, state)
- ExchangeCodeAsync(provider, code, redirectUri, ct)
- ValidateIdTokenAsync(provider, idToken, ct)
- IsAllowedDomain(provider, email, principal)
- UpsertUserAsync(provider, email, name, ct)
Va van giu overload cu de backward compatibility.
### 5.2 SsoService - logic tong
Core y tuong:
- Resolve config theo provider key.
- Provider nao khong co endpoint explicit thi suy ra tu metadata/authority.
- AzureAD duoc fallback tu AzureAdOptions de khong vo luong cu.
Phan quan trong:
1) ResolveProvider(...)
- Doc provider trong OAuthProviders.
- Neu key = azuread ma khong co trong OAuthProviders, fallback AzureAdOptions.
2) BuildAuthorizeUrl(...)
- Build URL authorize theo endpoint cua provider.
- Them scope, state, redirect_uri.
- Neu bat hosted-domain check thi them hd=<AllowedDomain>.
3) ExchangeCodeAsync(...)
- Goi token endpoint voi client_id, client_secret, code, redirect_uri.
- Parse OidcTokenResponse.
- Bat buoc co id_token (flow hien tai dang OIDC-centric).
4) ValidateIdTokenAsync(...)
- Lay metadata OIDC (.well-known/openid-configuration).
- Validate issuer, audience, signing key, lifetime.
5) IsAllowedDomain(...)
- Neu AllowedDomain rong -> cho phep.
- Neu khong rong -> check duoi email.
- Neu EnforceHostedDomainClaim bat va token co claim hd -> bat buoc hd trung AllowedDomain.
6) UpsertUserAsync(...)
- Tim user theo email (UserName).
- Neu ton tai: cap nhat ten + metadata update.
- Neu chua ton tai: tao user moi, Password = null, gan role PendingRoleName.
7) One-time code
- CreateOneTimeCodeAsync(...): tao code, luu bang SsoOneTimeCodes, het han sau 2 phut.
- ExchangeOneTimeCodeForLoginAsync(...): consume code, tra payload login final.
----------------------------------------
## 6) Controller va contract API
### 6.1 OAuthController (da provider)
Route base: api/auth/oauth
1) Login
- GET /api/auth/oauth/{provider}/login?returnUrl=...
- Redirect sang provider authorize URL.
2) Callback
- GET /api/auth/oauth/{provider}/callback?code=...&state=...
- Xu ly token + domain + upsert + issue token + one-time code.
- Redirect ve FE voi query code.
3) Exchange
- POST /api/auth/oauth/exchange
Request:
```json
{
"code": "one-time-code"
}
```
Response thanh cong:
```json
{
"token": "<jwt-noi-bo>",
"name": "...",
"username": "email@domain",
"access": [1, 2, 3],
"role": {
"roleName": "Pending",
"priority": 99
}
}
```
### 6.2 SsoController (AzureAD legacy)
Van giu route cu, nhung da goi service chung voi provider azuread.
----------------------------------------
## 7) Rule user va role Pending
Rule bat buoc:
- User OAuth lan dau phai duoc tao trong bang UserAccounts.
- User moi phai vao role Pending.
- Role Pending khong co permission nao (PermissionRoles.IsChecked = 0 hoac khong co row).
- Admin se cap role/permission sau.
Model lien quan:
- UserAccounts
- Roles
- PermissionRoles
- SsoOneTimeCodes
Kiem tra nhanh trong DB:
```sql
-- 1) Kiem tra role Pending co ton tai
SELECT Id, RoleName, Priority
FROM "Roles"
WHERE "RoleName" = 'Pending';
-- 2) Kiem tra Pending role khong co permission active
SELECT pr.*
FROM "PermissionRoles" pr
JOIN "Roles" r ON r."Id" = pr."RoleId"
WHERE r."RoleName" = 'Pending'
AND pr."IsChecked" = 1;
-- 3) Kiem tra user tao boi OAuth
SELECT "Id", "UserName", "Password", "RoleId", "CreatedBy", "UpdatedBy"
FROM "UserAccounts"
WHERE "UserName" = '<email-user>';
```
Ky vong:
- Query #2 tra ve 0 rows.
- User moi co Password = null, CreatedBy = 'SSO'.
----------------------------------------
## 8) Frontend integration (chi tiet)
1. Nguoi dung bam nut Google Login:
- Window.location -> GET /api/auth/oauth/google/login?returnUrl=<FE_CALLBACK_URL>
2. Sau callback backend, FE nhan code tu query string.
3. FE goi:
```http
POST /api/auth/oauth/exchange
Content-Type: application/json
{ "code": "<code-tu-query>" }
```
4. FE luu token va xu ly permission/role giong login cu.
----------------------------------------
## 9) Luu y bao mat va hardening
Nen lam tiep:
- Validate returnUrl theo allowlist de tranh open redirect.
- Luu va verify state/nonce server-side (cache/redis).
- Dung IHttpClientFactory thay new HttpClient() de kiem soat timeout/retry.
- Dua ClientSecret sang secret manager/env var.
- Bat HTTPS va secure cookie policy day du.
----------------------------------------
## 10) Troubleshooting
1) Loi IdToken not found in token response
- Provider dang khong tra OIDC token.
- Kiem tra scope co openid chua.
2) Loi Email not found in token
- Kiem tra claim trong Google token (email, preferred_username).
3) Loi Email domain is not allowed
- Check AllowedDomain va claim hd.
4) Loi Role 'Pending' not found
- Tao role Pending trong bang Roles.
5) Loi exchange code het han
- One-time code chi song 2 phut va chi dung 1 lan.
----------------------------------------
## 11) Checklist test E2E truoc khi merge
- Login Google thanh cong voi account hop le.
- User moi duoc tao trong DB va role = Pending.
- Pending role khong co permission active.
- User cu login lai thi khong tao duplicate user.
- One-time code chi dung 1 lan.
- AllowedDomain rong cho phep tat ca domain.
- AllowedDomain co gia tri thi chan dung domain.
----------------------------------------
## 12) Ghi chu pham vi hien tai
Hien tai flow moi dang OIDC-centric (bat buoc id_token).
Neu can support OAuth thuan (khong co id_token), can bo sung nhanh:
- UserInfo endpoint call.
- Mapping email/name tu userinfo response.
- Branch logic trong callback de fallback userinfo.

View File

@ -1,74 +0,0 @@
# User API
Tai lieu mo ta cac endpoint cap nhat role va thong tin nguoi dung.
----------------------------------------
## 1) Cap nhat thong tin nguoi dung
- PUT /api/User/{id}
- Permission: EDIT_USER_ROLE
### Request
```json
{
"name": "Nguyen Van A",
"userName": "nguyenvana",
"accessRooms": [1, 2, 3]
}
```
### Response (200)
```json
{
"success": true,
"message": "User updated successfully",
"data": {
"userId": 12,
"userName": "nguyenvana",
"name": "Nguyen Van A",
"roleId": 3,
"accessRooms": [1, 2, 3],
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Neu khong truyen `accessRooms` thi giu nguyen danh sach phong.
- Neu truyen `accessRooms` = [] thi xoa tat ca phong.
- Neu `userName` bi trung hoac khong hop le thi tra ve 400.
----------------------------------------
## 2) Cap nhat role nguoi dung
- PUT /api/User/{id}/role
- Permission: EDIT_USER_ROLE
### Request
```json
{
"roleId": 2
}
```
### Response (200)
```json
{
"success": true,
"message": "User role updated",
"data": {
"userId": 12,
"userName": "nguyenvana",
"roleId": 2,
"roleName": "Manager",
"updatedAt": "2026-04-03T10:20:30Z",
"updatedBy": "admin"
}
}
```
### Ghi chu
- Chi System Admin moi duoc phep cap nhat role System Admin.
----------------------------------------

View File

@ -3,13 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/computer-956.svg" />
<link rel="icon" href="/public/computer-956.svg" />
<meta name="theme-color" content="#000000" />
<meta
name="description"

View File

@ -1,210 +1,60 @@
# upstream backend {
# server 100.66.170.15:8080;
# server 127.0.0.1:8080;
# server 172.18.10.8:8080;
# }
upstream backend {
server 100.66.170.15:8080;
server 127.0.0.1:8080;
server 172.18.10.8:8080;
}
server {
listen 80;
server_name comp.soict.io;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name comp.soict.io;
ssl_certificate /etc/letsencrypt/live/comp.soict.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/comp.soict.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# MeshCentral proxied flow can set sizable auth cookies.
client_header_buffer_size 16k;
large_client_header_buffers 8 32k;
# Required when proxy_pass uses variables.
# In Docker, 127.0.0.11 is the embedded DNS resolver.
resolver 127.0.0.11 valid=30s ipv6=off;
resolver_timeout 5s;
set $backend_server ttmt-web:8080;
# Internal MeshCentral hop to avoid upstream TLS handshake instability.
set $meshserver meshcentral:8082;
# Public host MeshCentral expects in Host header.
set $meshhost soict-overleaf.tailc51e09.ts.net:8443;
root /usr/share/nginx/html;
# Default file to serve for directory requests
index index.html index.htm;
# MeshCentral auth entrypoint. If iframe/browser lands on /login due to
# redirect, keep it on MeshCentral instead of frontend routing.
location = /login {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# MeshCentral may redirect to "/" with remote params after login.
# Detect those requests and proxy them to MeshCentral instead of SPA.
location = / {
if ($arg_node != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
if ($arg_viewmode != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
if ($arg_gotonode != "") {
rewrite ^ /__mesh_root_proxy__ last;
}
try_files $uri $uri/ /index.html;
}
location = /__mesh_root_proxy__ {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
# Try to serve the requested file directly ($uri)
# If it's a directory, try serving the index file ($uri/)
# If neither exists, fall back to serving /index.html
try_files $uri $uri/ /index.html;
}
# Optional: Add cache control headers for static assets for better performance
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
access_log off;
access_log off; # Optional: Don't log accesses for static files
}
location /api/ {
proxy_pass http://$backend_server;
proxy_pass http://100.66.170.15:8080;
client_max_body_size 900M;
# Cho phép upload file lớn (vd: 200MB)
client_max_body_size 200M;
# Truyền thẳng stream sang backend
proxy_request_buffering off;
# Tăng timeout khi upload
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# CORS headers
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = OPTIONS) {
return 204;
}
}
location /api/Sse/events {
proxy_pass http://$backend_server;
proxy_pass http://backend/api/Sse/events;
proxy_http_version 1.1;
# cần thiết cho SSE
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
# MeshCentral client builds WebSocket URL from current location,
# e.g. wss://comp.soict.io/control.ashx.
location ~ ^/(control|meshrelay|commander|mesh)\.ashx$ {
proxy_pass http://$meshserver;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location = /api/meshcentral/proxy {
return 301 /api/meshcentral/proxy/;
}
location ~ ^/api/meshcentral/proxy/(.*)$ {
# Forward directly to MeshCentral via Tailscale HTTPS so MeshCentral receives
# requests over TLS and generates correct absolute URLs based on $meshhost.
proxy_pass https://soict-overleaf.tailc51e09.ts.net:8443/$1$is_args$args;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_set_header Host $meshhost;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# Keep browser navigation under /api/meshcentral/proxy/*.
proxy_redirect ~^https?://[^/]+(/.*)$ /api/meshcentral/proxy$1;
proxy_redirect ~^(/.*)$ /api/meshcentral/proxy$1;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# FE production currently builds mesh proxy path as /meshapi/api/meshcentral/proxy/...
location = /meshapi/api/meshcentral/proxy {
return 301 /meshapi/api/meshcentral/proxy/;
}
location ^~ /meshapi/api/meshcentral/proxy/ {
# Legacy frontend path -> backend MeshCentralProxyController
rewrite ^/meshapi/api/meshcentral/proxy/(.*)$ /$1 break;
proxy_pass http://$backend_server;
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}

560
package-lock.json generated
View File

@ -18,7 +18,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.23.0",
@ -26,7 +25,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
@ -38,7 +36,6 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -53,7 +50,6 @@
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"tw-animate-css": "^1.3.6",
@ -1120,9 +1116,9 @@
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "1.19.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
"engines": {
"node": ">=18.14.1"
},
@ -2857,40 +2853,6 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -3198,16 +3160,6 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
"node_modules/@tailwindcss/node": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
@ -3621,22 +3573,6 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.26",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
"dependencies": {
"@tanstack/virtual-core": "3.16.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/router-core": {
"version": "1.129.8",
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
@ -3801,15 +3737,6 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.16.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-file-routes": {
"version": "1.129.7",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
@ -3989,60 +3916,6 @@
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -4095,23 +3968,6 @@
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
"dev": true,
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -4434,37 +4290,13 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"dependencies": {
"follow-redirects": "^1.16.0",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-dead-code-elimination": {
@ -4562,9 +4394,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@ -5040,116 +4872,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@ -5204,11 +4926,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
},
"node_modules/decode-formdata": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
@ -5284,9 +5001,9 @@
"license": "MIT"
},
"node_modules/devalue": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="
},
"node_modules/diff": {
"version": "8.0.3",
@ -5420,15 +5137,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.8",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
@ -5512,11 +5220,6 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
},
"node_modules/eventsource": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@ -5611,11 +5314,11 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.5.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"dependencies": {
"ip-address": "^10.2.0"
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
@ -5648,9 +5351,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"funding": [
{
"type": "github",
@ -5724,9 +5427,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
@ -6025,9 +5728,9 @@
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
},
"node_modules/hono": {
"version": "4.12.23",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
"version": "4.12.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
"engines": {
"node": ">=16.9.0"
}
@ -6132,15 +5835,6 @@
}
]
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6161,18 +5855,10 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"engines": {
"node": ">= 12"
}
@ -6924,15 +6610,16 @@
}
},
"node_modules/nanoid": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -7227,9 +6914,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"engines": {
"node": ">=8.6"
},
@ -7246,9 +6933,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
@ -7263,8 +6950,9 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -7347,12 +7035,9 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/psl": {
"version": "1.15.0",
@ -7375,9 +7060,9 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
@ -7549,30 +7234,9 @@
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -7699,48 +7363,6 @@
"node": ">=0.10.0"
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -7762,11 +7384,6 @@
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@ -7888,9 +7505,9 @@
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
@ -8441,9 +8058,9 @@
}
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@ -8518,9 +8135,10 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
@ -8752,9 +8370,9 @@
}
},
"node_modules/unplugin/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"engines": {
"node": ">=12"
},
@ -8857,9 +8475,9 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@ -8876,31 +8494,10 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -9008,9 +8605,10 @@
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"engines": {
"node": ">=12"
},
@ -9092,10 +8690,11 @@
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@ -9252,10 +8851,11 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},

View File

@ -22,7 +22,6 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-form": "^1.23.0",
@ -30,7 +29,6 @@
"@tanstack/react-router": "^1.121.2",
"@tanstack/react-router-devtools": "^1.121.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.121.2",
"@tanstack/zod-form-adapter": "^0.42.1",
"axios": "^1.11.0",
@ -42,7 +40,6 @@
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"shadcn": "^2.9.3",
"sidebar": "^1.0.0",
"sonner": "^2.0.7",
@ -57,7 +54,6 @@
"@types/node": "^24.1.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"tw-animate-css": "^1.3.6",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -1,11 +0,0 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84"
}
}
}

View File

@ -1,226 +0,0 @@
import { useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
import { CommandType } from "@/types/command-registry";
import { toast } from "sonner";
interface DeviceActionBarProps {
roomName: string;
selectedDevices: any[];
onClearSelection: () => void;
}
const ACTIONS = [
{
type: CommandType.RESTART,
label: "Khởi động lại",
icon: Power,
variant: "outline" as const,
},
{
type: CommandType.SHUTDOWN,
label: "Tắt máy",
icon: PowerOff,
variant: "destructive" as const,
},
{
type: CommandType.TASKKILL,
label: "Kết thúc tác vụ",
icon: XCircle,
variant: "outline" as const,
},
{
type: CommandType.BLOCK,
label: "Chặn",
icon: ShieldBan,
variant: "outline" as const,
},
{
type: CommandType.RESET,
label: "Reset",
icon: RotateCcw,
variant: "destructive" as const,
},
];
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
export function DeviceActionBar({
roomName,
selectedDevices,
onClearSelection,
}: DeviceActionBarProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [activeType, setActiveType] = useState<CommandType | null>(null);
const [password, setPassword] = useState("");
const [isExecuting, setIsExecuting] = useState(false);
const getMachineNumber = useMachineNumber();
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
const executeSensitiveMutation = useExecuteSensitiveCommand();
const commandsByType = useMemo(() => {
return (Object.values(CommandType) as Array<number | string>)
.filter((value) => typeof value === "number")
.reduce((acc: Record<number, any[]>, type) => {
acc[type as number] = (sensitiveCommands || []).filter(
(command: any) => Number(command.command) === Number(type)
);
return acc;
}, {} as Record<number, any[]>);
}, [sensitiveCommands]);
const selectedCount = selectedDevices.length;
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
const buildDeviceLabel = (device: any) => {
const number = getMachineNumber(device?.id || "");
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
if (number > 0) {
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
}
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
};
const openConfirm = (type: CommandType) => {
if (!commandsByType[type]?.length) {
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
return;
}
setActiveType(type);
setConfirmOpen(true);
};
const handleClose = () => {
if (isExecuting) return;
setConfirmOpen(false);
setActiveType(null);
setPassword("");
};
const handleConfirm = async () => {
if (!activeCommand || !activeType) return;
if (!password.trim()) {
toast.error("Vui lòng nhập mật khẩu xác nhận.");
return;
}
setIsExecuting(true);
try {
await executeSensitiveMutation.mutateAsync({
roomName,
command: activeCommand.commandName,
password,
});
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
handleClose();
onClearSelection();
} catch (error) {
console.error("Execute command error:", error);
toast.error("Lỗi khi gửi lệnh!");
} finally {
setIsExecuting(false);
}
};
if (selectedCount === 0) return null;
return (
<>
<div className="sticky bottom-4 z-30">
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
Đã chọn {selectedCount} thiết bị
</div>
<div className="flex flex-wrap items-center gap-2">
{ACTIONS.map((action) => {
const Icon = action.icon;
const isDisabled = !commandsByType[action.type]?.length;
return (
<Button
key={action.type}
variant={action.variant}
size="sm"
disabled={isDisabled}
onClick={() => openConfirm(action.type)}
>
<Icon className="h-4 w-4" />
{action.label}
</Button>
);
})}
<Button variant="ghost" size="sm" onClick={onClearSelection}>
Bỏ chọn
</Button>
</div>
</div>
</div>
<Dialog open={confirmOpen} onOpenChange={handleClose}>
<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>{activeCommand?.commandName ?? ""}</strong>?
</p>
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
<p className="text-sm text-destructive">
Hành đng này không thể hoàn tác.
</p>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-sm font-medium">Thiết bị đưc chọn</div>
<ScrollArea className="max-h-40 rounded-lg border p-2">
<div className="space-y-1 text-sm">
{selectedDevices.map((device) => (
<div key={device.id} className="text-muted-foreground">
{buildDeviceLabel(device)}
</div>
))}
</div>
</ScrollArea>
<div>
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
<Input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Nhập mật khẩu để xác nhận"
/>
</div>
</div>
<DialogFooter className="flex gap-2 sm:gap-3">
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
Hủy
</Button>
<Button
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
onClick={handleConfirm}
disabled={isExecuting}
>
{isExecuting ? "Đang gửi..." : "Xác nhận"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -125,15 +125,6 @@ export function DeviceSearchDialog({
onClose();
};
const parseDeviceId = (id: string) => {
const match = /^P(.+?)M(\d+)$/i.exec(id.trim());
if (!match) return null;
return {
room: match[1].trim(),
index: Number(match[2]),
};
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
@ -165,31 +156,11 @@ export function DeviceSearchDialog({
const isExpanded = expandedRoom === room.name;
const isLoading = loadingRoom === room.name;
const devices = roomDevices[room.name] || [];
const sortedDevices = [...devices].sort((a, b) => {
const aId = String(a.id);
const bId = String(b.id);
const parsedA = parseDeviceId(aId);
const parsedB = parseDeviceId(bId);
if (parsedA && parsedB) {
const roomCompare = parsedA.room.localeCompare(parsedB.room, undefined, {
numeric: true,
sensitivity: "base",
});
if (roomCompare !== 0) return roomCompare;
return parsedA.index - parsedB.index;
}
return aId.localeCompare(bId, undefined, {
numeric: true,
sensitivity: "base",
});
});
const allSelected =
sortedDevices.length > 0 &&
sortedDevices.every((d) => selected.includes(d.id));
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
const selectedCount = sortedDevices.filter((d) =>
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;
@ -248,7 +219,7 @@ export function DeviceSearchDialog({
</div>
{/* Device table - collapsible */}
{isExpanded && sortedDevices.length > 0 && (
{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">
@ -272,7 +243,7 @@ export function DeviceSearchDialog({
</tr>
</thead>
<tbody>
{sortedDevices.map((device) => (
{devices.map((device) => (
<tr
key={device.id}
className="border-b last:border-b-0 hover:bg-muted/50"

View File

@ -109,7 +109,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
await executeSensitiveMutation.mutateAsync({
roomName,
command: confirmDialog.command.commandName,
command: confirmDialog.command.commandContent,
password: sensitivePassword,
});
@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
return (
<>
<div className="flex gap-2 flex-wrap items-center">
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
{Object.values(CommandType)
.filter((value) => typeof value === "number")
.map((commandType) => renderCommandButton(commandType as CommandType))}

View File

@ -1,46 +1,30 @@
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
import { useState, type MouseEvent } from "react";
import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { FolderStatusPopover } from "../folder-status-popover";
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
import type { ClientFolderStatus } from "@/types/folder";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
import { buildMeshProxyUrl } from "@/config/api";
import { toast } from "sonner";
export function ComputerCard({
device,
position,
folderStatus,
isCheckingFolder,
isSelected,
onSelect,
}: {
device: any | undefined;
position: number;
folderStatus?: ClientFolderStatus;
isCheckingFolder?: boolean;
isSelected?: boolean;
onSelect?: (event: MouseEvent<HTMLElement>) => void;
}) {
const [isConnecting, setIsConnecting] = useState(false);
const [showRemote, setShowRemote] = useState(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
if (!device) {
return (
<div className="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
<div className="flex items-center justify-between px-1.5 py-1 bg-muted/30">
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
<div className="relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/20">
<div className="absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold bg-muted text-muted-foreground">
{position}
</span>
</div>
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
<Monitor className="h-5 w-5 text-muted-foreground/20" />
<span className="text-[10px] text-muted-foreground/40">Trống</span>
</div>
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
<span className="text-xs text-muted-foreground">Trống</span>
</div>
);
}
@ -49,42 +33,6 @@ export function ComputerCard({
const firstNetworkInfo = device.networkInfos?.[0];
const agentVersion = device.version;
const handleConnect = async () => {
if (!device?.id) {
toast.error("Không tìm thấy nodeID của thiết bị.");
return;
}
try {
setIsConnecting(true);
const response = await getRemoteDesktopUrl(device.id);
const originalUrl = new URL(response.url);
const pathAndQuery = originalUrl.pathname + originalUrl.search;
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
setProxyUrl(proxyUrlFull);
setShowRemote(true);
} catch (error: any) {
toast.error(
error?.response?.data?.message || "Không thể kết nối remote cho thiết bị này."
);
} finally {
setIsConnecting(false);
}
};
const handleCloseRemote = () => {
setShowRemote(false);
setProxyUrl(null);
};
const handleFullscreen = () => {
const iframe = document.getElementById(`mesh-iframe-${device.id}`) as HTMLIFrameElement;
if (iframe?.requestFullscreen) {
iframe.requestFullscreen();
}
};
function DeviceFolderCheck() {
const deviceId = device.id;
const room = device.room;
@ -179,26 +127,6 @@ export function ComputerCard({
</div>
)}
<div>
<div className="text-xs text-muted-foreground mb-1">Kết nối</div>
<Button
type="button"
size="sm"
onClick={handleConnect}
disabled={isOffline || isConnecting}
className="w-full"
>
{isConnecting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Đang kết nối...
</>
) : (
"Connect"
)}
</Button>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
<DeviceFolderCheck />
@ -209,7 +137,7 @@ export function ComputerCard({
<Badge
variant={isOffline ? "destructive" : "default"}
className={`flex items-center gap-1 w-fit ${
isOffline ? "bg-slate-100 text-slate-600" : "bg-teal-50 text-teal-700"
isOffline ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
}`}
>
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
@ -220,37 +148,28 @@ export function ComputerCard({
);
return (
<>
<Popover>
<PopoverTrigger asChild>
<div
onClick={onSelect}
className={cn(
"flex flex-col items-stretch w-[88px] rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer select-none",
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
isOffline
? "border-slate-400 bg-white hover:border-slate-500"
: "border-teal-400 bg-white hover:border-teal-500",
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
? "bg-red-50 border-red-300 hover:border-red-400 hover:shadow-lg"
: "bg-green-50 border-green-300 hover:border-green-400 hover:shadow-lg"
)}
>
{/* Top bar: position + folder status */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-slate-500" : "bg-teal-600"
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
{!isOffline && (
<div
onClick={(e) => e.stopPropagation()}
className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-teal-500 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white"
>
</div>
{/* Folder Status Icon */}
{device && !isOffline && (
<div className="absolute -top-2 -right-2">
<FolderStatusPopover
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
status={folderStatus}
@ -258,34 +177,27 @@ export function ComputerCard({
/>
</div>
)}
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
<Monitor
className={cn(
"h-5 w-5",
isOffline ? "text-slate-300" : "text-teal-500"
)}
/>
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
{firstNetworkInfo?.ipAddress && (
<div className="text-[9px] font-mono text-center leading-tight w-full truncate text-muted-foreground px-0.5">
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
{firstNetworkInfo.ipAddress}
</div>
)}
{agentVersion && (
<div className="text-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
v{agentVersion}
</div>
)}
<div
</div>
)}
<div className="flex items-center gap-1">
<span
className={cn(
"text-[10px] font-semibold leading-none mt-0.5",
isOffline ? "text-slate-500" : "text-teal-600"
"text-xs font-medium",
isOffline ? "text-red-700" : "text-green-700"
)}
>
{isOffline ? "Off" : "On"}
</div>
</span>
</div>
</div>
</PopoverTrigger>
@ -293,44 +205,5 @@ export function ComputerCard({
<DeviceInfo />
</PopoverContent>
</Popover>
{showRemote && proxyUrl && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<p className="text-sm font-medium">Remote Session - {device.id}</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleFullscreen}
title="Fullscreen"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleCloseRemote}
aria-label="Đóng"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<iframe
id={`mesh-iframe-${device.id}`}
title="Remote Desktop"
src={proxyUrl}
className="h-[calc(90vh-44px)] w-full border-0"
allowFullScreen
allow="clipboard-read; clipboard-write; camera; microphone"
/>
</div>
</div>
)}
</>
);
}

View File

@ -1,98 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import type { DeviceOverviewResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function DeviceOverviewCard({
data,
isLoading = false,
}: {
data?: DeviceOverviewResponse | null;
isLoading?: boolean;
}) {
const pieData = [
{ name: "Online", value: data?.onlineDevices ?? 0 },
{ name: "Offline", value: data?.offlineDevices ?? 0 },
];
const COLORS = ["#22c55e", "#ef4444"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tổng quan thiết bị</CardTitle>
<CardDescription>Trạng thái chung các thiết bị offline gần đây</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Tổng thiết bị</div>
<div className="text-2xl font-bold">{data?.totalDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Agent chưa đưc cập nhật</div>
<div className="text-2xl font-bold">{data?.devicesWithOutdatedVersion ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Online</div>
<div className="text-2xl font-bold">{data?.onlineDevices ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40">
<div className="text-xs text-muted-foreground">Offline</div>
<div className="text-2xl font-bold">{data?.offlineDevices ?? 0}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ Online / Offline</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
innerRadius={30}
outerRadius={60}
label
>
{pieData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<div className="text-sm font-medium">Thiết bị offline gần đây</div>
<div className="mt-2 max-h-40 overflow-auto divide-y divide-muted/40">
{data?.recentOfflineDevices && data.recentOfflineDevices.length > 0 ? (
data.recentOfflineDevices.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between py-2">
<div>
<div className="font-medium">{d.deviceId}</div>
<div className="text-xs text-muted-foreground">{d.room ?? "-"}</div>
</div>
<div className="text-xs text-muted-foreground">
{d.lastSeen ? new Date(d.lastSeen).toLocaleString() : "-"}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không thiết bị offline gần đây</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,74 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { RoomManagementResponse, RoomHealthStatus } from "@/types/dashboard";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts";
function statusBadge(status?: string) {
if (!status) return <Badge>Unknown</Badge>;
if (status === "InSession") return <Badge className="bg-green-100 text-green-700">Đang sử dụng</Badge>;
if (status === "NotInUse") return <Badge className="bg-red-100 text-red-700">Không sử dụng</Badge>;
return <Badge className="bg-yellow-100 text-yellow-700"> thể lớp học</Badge>;
}
export function RoomManagementCard({
data,
isLoading = false,
}: {
data?: RoomManagementResponse | null;
isLoading?: boolean;
}) {
const chartData = (data?.rooms ?? []).map((r) => ({ room: r.roomName, health: r.healthPercentage ?? 0 }));
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan các phòng đang không sử dụng</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<div className="text-xs text-muted-foreground">Tổng phòng</div>
<div className="text-2xl font-bold">{data?.totalRooms ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium mb-2">Tỉ lệ thiết bị online</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" domain={[0, 100]} />
<YAxis dataKey="room" type="category" width={110} />
<Tooltip />
<Bar dataKey="health" fill="#0ea5e9" radius={[4, 4, 4, 4]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Phòng không dùng</div>
<div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => (
<div key={r.roomName} className="flex items-center justify-between">
<div>
<div className="font-medium">{r.roomName}</div>
<div className="text-xs text-muted-foreground">{r.totalDevices} thiết bị</div>
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-medium">{r.healthPercentage?.toFixed(1) ?? "-"}%</div>
{statusBadge(r.healthStatus)}
</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không phòng cần chú ý</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,88 +0,0 @@
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { SoftwareDistributionResponse } from "@/types/dashboard";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
export function SoftwareDistributionCard({
data,
isLoading = false,
}: {
data?: SoftwareDistributionResponse | null;
isLoading?: boolean;
}) {
void isLoading;
const distData = [
{ name: "Success", value: data?.successfulInstallations ?? 0 },
{ name: "Failed", value: data?.failedInstallations ?? 0 },
{ name: "Pending", value: data?.pendingInstallations ?? 0 },
];
const COLORS = ["#10b981", "#ef4444", "#f59e0b"];
return (
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Phân phối phần mềm</CardTitle>
<CardDescription>Thống cài đt lỗi phổ biến</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-3">
<div>
<div className="text-xs text-muted-foreground">Tổng log</div>
<div className="text-2xl font-bold">{data?.totalInstallations ?? 0}</div>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thành công</div>
<div className="text-2xl font-bold">{data?.successfulInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Thất bại</div>
<div className="text-2xl font-bold">{data?.failedInstallations ?? 0}</div>
</div>
<div className="p-3 rounded-lg bg-muted/30 border border-muted/40 text-center">
<div className="text-xs text-muted-foreground">Đang chờ</div>
<div className="text-2xl font-bold">{data?.pendingInstallations ?? 0}</div>
</div>
</div>
<div className="mt-4">
<div className="text-sm font-medium">Top lỗi</div>
<div className="mt-2 space-y-2">
{data?.topFailedSoftware && data.topFailedSoftware.length > 0 ? (
data.topFailedSoftware.map((t) => (
<div key={t.fileName} className="flex items-center justify-between">
<div className="truncate max-w-[180px]">{t.fileName}</div>
<Badge className="bg-red-100 text-red-700">{t.failCount}</Badge>
</div>
))
) : (
<div className="text-sm text-muted-foreground">Không lỗi phổ biến</div>
)}
</div>
</div>
</div>
<div>
<div className="text-sm font-medium mb-2">Tỉ lệ trạng thái cài đt</div>
<div className="h-40">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" innerRadius={30} outerRadius={60} label>
{distData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,23 +0,0 @@
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
export const agentColumns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
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ập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];

View File

@ -1,77 +0,0 @@
// components/columns/apps-column.tsx
import type { Version } from "@/types/file";
import type { ColumnDef } from "@tanstack/react-table";
import { Check, X } from "lucide-react";
// Không gọi hook ở đây — nhận isPending từ ngoài truyền vào
export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
return [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
accessorKey: "syncFolder",
header: "Thư mục đồng bộ",
cell: ({ getValue }) => (
<code className="text-xs">{(getValue() as string) || "C:/Setup"}</code>
),
},
{
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={isPending} // ← nhận từ tham số, không gọi hook
/>
),
enableSorting: false,
enableHiding: false,
},
];
}

View File

@ -1,98 +0,0 @@
import { type ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import type { Audits } from "@/types/audit";
export const auditColumns: ColumnDef<Audits>[] = [
{
header: "Thời gian",
accessorKey: "dateTime",
cell: ({ getValue }) => {
const v = getValue() as string;
const d = v ? new Date(v) : null;
return d ? (
<div className="text-sm whitespace-nowrap">
<div className="font-medium">{d.toLocaleDateString("vi-VN")}</div>
<div className="text-muted-foreground text-xs">
{d.toLocaleTimeString("vi-VN")}
</div>
</div>
) : (
<span className="text-muted-foreground"></span>
);
},
},
{
header: "User",
accessorKey: "username",
cell: ({ getValue }) => (
<span className="font-medium text-sm whitespace-nowrap">
{getValue() as string}
</span>
),
},
{
header: "Loại",
accessorKey: "apiCall",
cell: ({ getValue }) => {
const v = (getValue() as string) ?? "";
if (!v) return <span className="text-muted-foreground"></span>;
return (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{v}
</code>
);
},
},
{
header: "Hành động",
accessorKey: "action",
cell: ({ getValue }) => (
<code className="text-xs bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
{getValue() as string}
</code>
),
},
{
header: "URL",
accessorKey: "url",
cell: ({ getValue }) => (
<code className="text-xs text-muted-foreground max-w-[180px] truncate block">
{(getValue() as string) ?? "—"}
</code>
),
},
{
header: "Kết quả",
accessorKey: "isSuccess",
cell: ({ getValue }) => {
const v = getValue();
if (v == null) return <span className="text-muted-foreground"></span>;
return v ? (
<Badge variant="outline" className="text-green-600 border-green-600 whitespace-nowrap">
Thành công
</Badge>
) : (
<Badge variant="outline" className="text-red-600 border-red-600 whitespace-nowrap">
Thất bại
</Badge>
);
},
},
{
header: "Nội dung request",
accessorKey: "requestPayload",
cell: ({ getValue }) => {
const v = getValue() as string;
if (!v) return <span className="text-muted-foreground"></span>;
let preview = v;
try {
preview = JSON.stringify(JSON.parse(v));
} catch {}
return (
<span className="text-xs text-muted-foreground max-w-[200px] truncate block">
{preview}
</span>
);
},
},
];

View File

@ -1,180 +0,0 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import type { Audits } from "@/types/audit";
function JsonDisplay({ value }: { value: string | null | undefined }) {
if (!value) return <span className="text-muted-foreground"></span>;
try {
return (
<pre className="text-xs bg-muted/60 p-2.5 rounded-md overflow-auto whitespace-pre-wrap break-all leading-relaxed max-h-48 font-mono">
{JSON.stringify(JSON.parse(value), null, 2)}
</pre>
);
} catch {
return <span className="text-xs break-all font-mono">{value}</span>;
}
}
interface AuditDetailDialogProps {
audit: Audits | null;
open: boolean;
onClose: () => void;
}
export function AuditDetailDialog({
audit,
open,
onClose,
}: AuditDetailDialogProps) {
if (!audit) return null;
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-2xl w-full max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Chi tiết audit
<span className="text-muted-foreground font-normal text-sm">
#{audit.id}
</span>
</DialogTitle>
</DialogHeader>
<Separator />
<div className="grid grid-cols-2 gap-x-6 gap-y-3 pt-1">
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Thời gian
</p>
<p className="text-sm font-medium">
{audit.dateTime
? new Date(audit.dateTime).toLocaleString("vi-VN")
: "—"}
</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
User
</p>
<p className="text-sm font-medium">{audit.username}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
API Call
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.apiCall ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<div>
{audit.isSuccess == null ? (
<span className="text-muted-foreground text-sm"></span>
) : audit.isSuccess ? (
<Badge
variant="outline"
className="text-green-600 border-green-600"
>
Thành công
</Badge>
) : (
<Badge
variant="outline"
className="text-red-600 border-red-600"
>
Thất bại
</Badge>
)}
</div>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Hành đng
</p>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{audit.action}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
URL
</p>
<code className="text-xs text-muted-foreground break-all">
{audit.url ?? "—"}
</code>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Bảng
</p>
<p className="text-sm">{audit.tableName ?? "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Entity ID
</p>
<p className="text-sm">{audit.entityId ?? "—"}</p>
</div>
<div className="col-span-2 space-y-0.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Lỗi
</p>
<p className="text-sm text-red-600">{audit.errorMessage ?? "—"}</p>
</div>
</div>
<Separator />
<div className="space-y-4">
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Nội dung request
</p>
<JsonDisplay value={audit.requestPayload} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị
</p>
<JsonDisplay value={audit.oldValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Giá trị mới
</p>
<JsonDisplay value={audit.newValues} />
</div>
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground uppercase tracking-wide">
Kết quả
</p>
<p className="text-sm">{audit.isSuccess == null ? "—" : audit.isSuccess ? "Thành công" : "Thất bại"}</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { useEffect, useMemo, useState } from "react";
import { useState, useMemo } from "react";
export interface SelectItem {
label: string;
@ -16,7 +16,6 @@ interface SelectDialogProps {
description?: string;
icon?: React.ReactNode;
items: SelectItem[];
selectedValues?: string[];
onConfirm: (values: string[]) => Promise<void> | void;
}
@ -27,18 +26,11 @@ export function SelectDialog({
description,
icon,
items,
selectedValues,
onConfirm,
}: SelectDialogProps) {
const [selected, setSelected] = useState<string[]>([]);
const [search, setSearch] = useState("");
useEffect(() => {
if (!open) return;
if (!selectedValues) return;
setSelected(selectedValues);
}, [open, selectedValues]);
const filteredItems = useMemo(() => {
return items.filter((item) =>
item.label.toLowerCase().includes(search.toLowerCase())

View File

@ -1,73 +0,0 @@
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface AuditFilterBarProps {
username: string | null;
action: string | null;
from: string | null;
to: string | null;
isLoading: boolean;
isFetching: boolean;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
}
export function AuditFilterBar({
username,
action,
from,
to,
isLoading,
isFetching,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
}: AuditFilterBarProps) {
return (
<div className="flex gap-2 mb-4 flex-wrap items-end">
<Input
className="w-36"
placeholder="Username"
value={username ?? ""}
onChange={(e) => onUsernameChange(e.target.value || null)}
/>
<Input
className="w-44"
placeholder="Hành động..."
value={action ?? ""}
onChange={(e) => onActionChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={from ?? ""}
onChange={(e) => onFromChange(e.target.value || null)}
/>
<Input
className="w-36"
type="date"
value={to ?? ""}
onChange={(e) => onToChange(e.target.value || null)}
/>
<div className="flex gap-2">
<Button onClick={onSearch} disabled={isFetching || isLoading} size="sm">
Tìm
</Button>
<Button variant="outline" onClick={onReset} size="sm">
Reset
</Button>
</div>
</div>
);
}

View File

@ -32,7 +32,6 @@ export interface CommandRegistryFormData {
commandContent: string;
qos: 0 | 1 | 2;
isRetained: boolean;
ttlMinutes: number;
}
// Zod validation schema
@ -54,7 +53,6 @@ const commandRegistrySchema = z.object({
.trim(),
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
isRetained: z.boolean(),
ttlMinutes: z.number().int().min(-1, "TTL tối thiểu là -1 (vô hạn)"),
});
const QoSLevels = [
@ -100,7 +98,6 @@ export function CommandRegistryForm({
commandContent: initialData?.commandContent || "",
qos: (initialData?.qos || 0) as 0 | 1 | 2,
isRetained: initialData?.isRetained || false,
ttlMinutes: initialData?.ttlMinutes ?? -1,
},
onSubmit: async ({ value }) => {
try {
@ -186,7 +183,6 @@ export function CommandRegistryForm({
<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>
<option value={CommandType.RESET}>RESET - Đt lại</option>
</select>
{field.state.meta.errors?.length > 0 && (
<p className="text-sm text-red-500">
@ -391,31 +387,6 @@ export function CommandRegistryForm({
)}
</form.Field>
{/* TTL Minutes */}
<form.Field name="ttlMinutes">
{(field: any) => (
<div className="space-y-2">
<Label>TTL (Phút)</Label>
<Input
type="number"
placeholder="-1"
value={field.state.value}
onChange={(e) => field.handleChange(Number(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">
Thời gian tồn tại của lệnh retained (phút). -1 = hạn.
</p>
</div>
)}
</form.Field>
{/* Submit Button */}
<div className="flex gap-3 pt-4">
<Button

View File

@ -4,7 +4,7 @@ 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 { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
import { login } from "@/services/auth.service";
import { useState } from "react";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Route } from "@/routes/(auth)/login";
@ -44,24 +44,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
}
});
const handleGoogleLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "google");
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
};
const handleMicrosoftLogin = () => {
const returnUrl = new URL("/oauth/callback", window.location.origin);
returnUrl.searchParams.set("provider", "azuread");
if (search.redirect) {
returnUrl.searchParams.set("redirect", search.redirect);
}
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
@ -71,10 +53,10 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
return (
<div className={cn("flex flex-col gap-6", className)}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
<img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
<span>Computer Management</span>
<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>
@ -121,37 +103,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
Đăng nhập
</Button>
)}
<div className="text-center text-sm text-muted-foreground">Hoặc</div>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleGoogleLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.02 5.02 0 0 1-2.18 3.29v2.74h3.52c2.05-1.89 3.3-4.67 3.3-8.04Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.52-2.74c-.98.66-2.23 1.06-3.76 1.06-2.89 0-5.33-1.95-6.2-4.56H2.18v2.84A11 11 0 0 0 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.8 14.1A6.62 6.62 0 0 1 5.45 12c0-.73.13-1.44.35-2.1V7.06H2.18A11 11 0 0 0 1 12c0 1.77.42 3.44 1.18 4.94L5.8 14.1Z"
/>
<path
fill="#EA4335"
d="M12 5.34c1.61 0 3.05.56 4.18 1.64l3.14-3.14C17.45 2.09 14.97 1 12 1a11 11 0 0 0-9.82 6.06L5.8 9.9C6.67 7.29 9.11 5.34 12 5.34Z"
/>
</svg>
Đăng nhập với Google
</Button>
<Button type="button" variant="outline" className="w-full gap-2" onClick={handleMicrosoftLogin}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<rect x="2" y="2" width="9" height="9" fill="#F35325" />
<rect x="13" y="2" width="9" height="9" fill="#81BC06" />
<rect x="2" y="13" width="9" height="9" fill="#05A6F0" />
<rect x="13" y="13" width="9" height="9" fill="#FFBA08" />
</svg>
Đăng nhập với Microsoft
</Button>
</div>
</form>
</CardContent>

View File

@ -18,14 +18,14 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
const [isDone, setIsDone] = useState(false);
// Match server allowed extensions
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
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: "", syncFolder: "" },
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");
@ -49,7 +49,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
const fd = new FormData();
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
fd.append("Version", value.newVersion);
if (value.syncFolder) fd.append("SyncFolder", value.syncFolder);
await onSubmit(fd, {
onUploadProgress: (e: AxiosProgressEvent) => {
@ -92,23 +91,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
)}
</form.Field>
<form.Field name="syncFolder">
{(field) => (
<div>
<Label>Thư mục đng bộ</Label>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="C:/Setup"
disabled={isUploading || isDone}
/>
<p className="text-xs text-muted-foreground mt-1">
Đ trống = mặc đnh <code>C:/Setup</code>
</p>
</div>
)}
</form.Field>
<form.Field name="files">
{(field) => (
<div>

View File

@ -1,122 +0,0 @@
import { useMemo, type MouseEvent } from "react";
import { cn } from "@/lib/utils";
import { useMachineNumber } from "@/hooks/useMachineNumber";
interface DeviceGridCompactProps {
devices: any[];
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}
export function DeviceGridCompact({
devices,
selectedIds = [],
onSelectDevice,
}: DeviceGridCompactProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const items = useMemo(() => {
return [...devices]
.map((device, index) => ({
device,
index,
number: getMachineNumber(device?.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
if (aNumber !== bNumber) return aNumber - bNumber;
return a.index - b.index;
});
}, [devices, getMachineNumber]);
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2">
{items.map((item, index) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
const version = device?.version;
const titleParts = [`#${position}`];
if (ipAddress) titleParts.push(`IP: ${ipAddress}`);
if (version) titleParts.push(`v${version}`);
const isOffline = device?.isOffline;
const isSelected = selectedSet.has(device?.id);
// last 2 octets of IP for compact display
const shortIp = ipAddress
? ipAddress.split(".").slice(-2).join(".")
: null;
return (
<button
key={device?.id || `device-${item.index}`}
type="button"
title={titleParts.join(" | ")}
onClick={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, index, event);
}}
className={cn(
"flex flex-col items-stretch rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer",
isOffline
? "border-slate-400 bg-white hover:border-slate-500"
: "border-teal-400 bg-white hover:border-teal-500",
isSelected &&
"ring-2 ring-primary ring-offset-1 ring-offset-background"
)}
>
{/* Top color bar with position number */}
<div
className={cn(
"flex items-center justify-between px-1.5 py-1",
isOffline ? "bg-slate-500" : "bg-teal-600"
)}
>
<span
className="text-[11px] font-bold text-white leading-none"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
>
{position}
</span>
<span
className={cn(
"h-1.5 w-1.5 rounded-full border border-white/40",
isOffline ? "bg-slate-200" : "bg-teal-200"
)}
/>
</div>
{/* Body */}
<div className="flex flex-col items-center justify-center gap-0.5 py-1.5 px-1">
{shortIp ? (
<span
className="font-mono text-muted-foreground truncate w-full text-center leading-tight"
style={{ fontSize: "9px" }}
>
{shortIp}
</span>
) : (
<span className="text-[9px] text-muted-foreground/50"></span>
)}
<span
className={cn(
"text-[10px] font-semibold leading-none",
isOffline ? "text-slate-500" : "text-teal-600"
)}
>
{isOffline ? "Off" : "On"}
</span>
</div>
</button>
);
})}
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useMemo, type MouseEvent } from "react";
import { Monitor, DoorOpen } from "lucide-react";
import { ComputerCard } from "../cards/computer-card";
import { useMachineNumber } from "../../hooks/useMachineNumber";
@ -8,146 +7,68 @@ export function DeviceGrid({
devices,
folderStatuses,
isCheckingFolder,
totalSeats,
selectedIds = [],
onSelectDevice,
}: {
devices: any[];
folderStatuses?: Map<string, ClientFolderStatus>;
isCheckingFolder?: boolean;
totalSeats?: number;
selectedIds?: string[];
onSelectDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
}) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const parsedDevices = devices
.map((device, index) => ({
device,
index,
number: getMachineNumber(device.id || ""),
}))
.sort((a, b) => {
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
const deviceMap = new Map<number, any>();
if (aNumber !== bNumber) {
return aNumber - bNumber;
}
return a.index - b.index;
devices.forEach((device) => {
const number = getMachineNumber(device.id || "");
if (number > 0 && number <= 40) deviceMap.set(number, device);
});
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
...item,
orderIndex,
}));
const totalRows = 5;
const seatCount =
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
const rightCapacity = Math.ceil(seatCount / 2);
const inRangeCount = orderedDevices.filter(
(item) => item.number > 0 && item.number <= seatCount
).length;
const useThresholdSplit =
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
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;
let rightDevices = orderedDevices;
let leftDevices: typeof orderedDevices = [];
if (useThresholdSplit) {
rightDevices = orderedDevices.filter(
(item) => item.number > 0 && item.number <= rightCapacity
);
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
const unassigned = orderedDevices.filter(
(item) => item.number <= 0 || item.number > seatCount
);
leftDevices = [...leftDevices, ...unassigned];
} else {
const splitIndex = Math.ceil(orderedDevices.length / 2);
rightDevices = orderedDevices.slice(0, splitIndex);
leftDevices = orderedDevices.slice(splitIndex);
}
const renderDevice = (item: (typeof orderedDevices)[number]) => {
const device = item.device;
const position = item.number > 0 ? item.number : item.index + 1;
return (
<div key={rowIndex} className="flex items-center justify-center gap-3">
{/* Bên trái (2140) */}
{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 isSelected = device?.id ? selectedSet.has(device.id) : false;
return (
<ComputerCard
key={device?.id || `device-${item.index}`}
key={pos}
device={device}
position={position}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
isSelected={isSelected}
onSelect={(event) => {
if (!device?.id) return;
onSelectDevice?.(device.id, item.orderIndex, event);
}}
/>
);
};
})}
const columnsPerSide = 4;
const chunkRows = <T,>(items: T[], size: number) => {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) {
rows.push(items.slice(i, i + size));
}
return rows;
};
{/* Đường chia giữa */}
<div className="w-32 flex items-center justify-center">
<div className="h-px w-full bg-border border-t-2 border-dashed" />
</div>
const leftRows = chunkRows(leftDevices, columnsPerSide);
const rightRows = chunkRows(rightDevices, columnsPerSide);
const totalRows = Math.max(leftRows.length, rightRows.length);
const renderPlaceholder = (key: string) => (
<div key={key} className="w-24 h-24 shrink-0" aria-hidden="true" />
);
const renderRow = (rowIndex: number) => {
const leftRow = leftRows[rowIndex] ?? [];
const rightRow = rightRows[rowIndex] ?? [];
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
const rightFill = Math.max(0, columnsPerSide - rightRow.length);
// Cả 2 panel đều mirror: số nhỏ nhất sát divider, tăng ra ngoài
// Right: [8,7,6,5,4,3,2,1 | divider] Left: [divider | 9,10,11,12,13,14,15,16]
// Nhìn từ bàn GV (phải) sang trái: 1,2,3,4,... liên tục
const rightRowReversed = [...rightRow].reverse();
const leftRowReversed = [...leftRow].reverse();
{/* Bên phải (120) */}
{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);
return (
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
<div className="flex items-center gap-3">
{Array.from({ length: leftFill }).map((_, i) =>
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
)}
{leftRowReversed.map(renderDevice)}
</div>
<div className="w-10 flex items-center justify-center">
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
</div>
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
<div className="flex items-center gap-3">
{rightRowReversed.map(renderDevice)}
{Array.from({ length: rightFill }).map((_, i) =>
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
)}
</div>
<ComputerCard
key={pos}
device={device}
position={pos}
folderStatus={folderStatus}
isCheckingFolder={isCheckingFolder}
/>
);
})}
</div>
);
};
@ -155,7 +76,7 @@ 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(totalRows - 1 - i))}
{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">

View File

@ -1,89 +0,0 @@
import { useMemo } from "react";
import type { Room } from "@/types/room";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface RoomListPanelProps {
rooms: Room[];
activeRoomName?: string;
onSelectRoom: (roomName: string) => void;
}
const formatDeviceCount = (value: number) => {
if (!Number.isFinite(value)) return "0";
if (value < 1000) return String(value);
const k = value / 1000;
const needsDecimal = value < 10000 && value % 1000 !== 0;
const formatted = k.toFixed(needsDecimal ? 1 : 0).replace(/\.0$/, "");
return `${formatted}k`;
};
export function RoomListPanel({
rooms,
activeRoomName,
onSelectRoom,
}: RoomListPanelProps) {
const sortedRooms = useMemo(() => {
return [...rooms].sort((a, b) => {
const nameA = String(a?.name ?? "");
const nameB = String(b?.name ?? "");
return nameA.localeCompare(nameB, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [rooms]);
return (
<div className="w-[200px] shrink-0 overflow-hidden rounded-xl border bg-background shadow-sm">
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Danh sách phòng
</span>
<Badge variant="secondary" className="text-[10px]">
{rooms.length}
</Badge>
</div>
<ScrollArea className="h-[calc(100vh-240px)] p-2">
<div className="space-y-1.5">
{sortedRooms.length === 0 && (
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
Chưa phòng
</div>
)}
{sortedRooms.map((room) => {
const isActive = room.name === activeRoomName;
const hasOffline = room.numberOfOfflineDevices > 0;
return (
<button
key={room.name}
type="button"
onClick={() => onSelectRoom(room.name)}
className={cn(
"w-full flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
isActive
? "border-border/60 bg-background text-foreground shadow-sm"
: "border-transparent text-muted-foreground hover:bg-muted/40"
)}
>
<span
className={cn(
"h-2 w-2 rounded-full",
hasOffline ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="flex-1 truncate font-medium text-foreground">
{room.name}
</span>
<Badge variant="secondary" className="text-[10px]">
{formatDeviceCount(room.numberOfDevices)}
</Badge>
</button>
);
})}
</div>
</ScrollArea>
</div>
);
}

View File

@ -5,8 +5,6 @@ import {
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { useMemo, useRef, type MouseEvent } from "react";
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
import {
Table,
TableBody,
@ -18,7 +16,6 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { FolderStatusPopover } from "../folder-status-popover";
@ -26,13 +23,6 @@ import { FolderStatusPopover } from "../folder-status-popover";
interface DeviceTableProps {
devices: any[];
isCheckingFolder?: boolean;
selectedIds?: string[];
onToggleDevice?: (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => void;
onToggleAll?: (checked: boolean) => void;
}
/**
@ -41,46 +31,10 @@ interface DeviceTableProps {
export function DeviceTable({
devices,
isCheckingFolder,
selectedIds = [],
onToggleDevice,
onToggleAll,
}: DeviceTableProps) {
const getMachineNumber = useMachineNumber();
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id));
const someSelected = devices.some((d) => selectedSet.has(d.id));
const selectionEnabled = Boolean(onToggleDevice || onToggleAll);
const selectionColumn: ColumnDef<any> = {
id: "select",
header: () => (
<div className="flex items-center justify-center">
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={(value) => onToggleAll?.(value === true)}
aria-label="Chọn tất cả"
/>
</div>
),
cell: ({ row }) => {
const device = row.original;
return (
<div className="flex items-center justify-center">
<Checkbox
checked={selectedSet.has(device.id)}
onClick={(event) => {
event.stopPropagation();
onToggleDevice?.(device.id, row.index, event);
}}
aria-label="Chọn thiết bị"
/>
</div>
);
},
};
const columns: ColumnDef<any>[] = [
...(selectionEnabled ? [selectionColumn] : []),
{
header: "STT",
cell: ({ row }) => {
@ -154,11 +108,11 @@ export function DeviceTable({
key={idx}
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
>
<span className="text-primary">*</span>
<span className="text-primary"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.macAddress ?? "-"}
</code>
<span className="text-muted-foreground">-&gt;</span>
<span className="text-muted-foreground"></span>
<code className="bg-background px-2 py-0.5 rounded">
{info.ipAddress ?? "-"}
</code>
@ -217,24 +171,8 @@ export function DeviceTable({
initialState: { pagination: { pageSize: 16 } },
});
const parentRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
overscan: 8,
});
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom =
virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0;
const columnCount = table.getVisibleLeafColumns().length;
return (
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((headerGroup) => (
@ -248,40 +186,15 @@ export function DeviceTable({
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingTop}px` }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<TableRow
key={row.id}
className="hover:bg-muted/50 transition-colors"
style={{ height: `${virtualRow.size}px` }}
>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})}
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={columnCount}
className="p-0"
style={{ height: `${paddingBottom}px` }}
/>
</TableRow>
)}
))}
</TableBody>
</Table>

View File

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

View File

@ -1,27 +0,0 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@ -1,48 +1,12 @@
const isDev = import.meta.env.MODE === "development";
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
export const BASE_URL = isDev
? import.meta.env.VITE_API_URL_DEV
: "/api";
export const BASE_MESH_URL = isDev
? (import.meta.env.VITE_API_MESH || import.meta.env.VITE_API_MESH_DEV || "")
: (import.meta.env.VITE_API_MESH || "");
export const buildMeshProxyUrl = (meshPathAndQuery: string) => {
const cleanPath = meshPathAndQuery.startsWith("/")
? meshPathAndQuery.substring(1)
: meshPathAndQuery;
const proxyPath = `/api/meshcentral/proxy/${cleanPath}`;
// If an explicit mesh host is configured, always use it.
// This allows forcing proxy URLs to https://<IP>:<port>/api/meshcentral/proxy/...
if (BASE_MESH_URL && BASE_MESH_URL.startsWith("http")) {
return `${trimTrailingSlash(BASE_MESH_URL)}${proxyPath}`;
}
// In development, BASE_URL is usually absolute (e.g. http://localhost:5218/api).
// Build an absolute proxy URL to backend so iframe requests do not hit Vite dev server.
if (BASE_URL.startsWith("http")) {
const apiBase = trimTrailingSlash(BASE_URL);
const backendOrigin = apiBase.endsWith("/api")
? apiBase.slice(0, -4)
: apiBase;
return `${backendOrigin}${proxyPath}`;
}
return proxyPath;
};
export const API_ENDPOINTS = {
AUTH: {
LOGIN: `${BASE_URL}/login`,
OAUTH_LOGIN: (provider: string) =>
`${BASE_URL}/auth/oauth/${encodeURIComponent(provider)}/login`,
OAUTH_EXCHANGE: `${BASE_URL}/auth/oauth/exchange`,
SSO_LOGIN: `${BASE_URL}/auth/sso/login`,
SSO_EXCHANGE: `${BASE_URL}/auth/sso/exchange`,
LOGOUT: `${BASE_URL}/logout`,
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
@ -51,10 +15,6 @@ export const API_ENDPOINTS = {
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
GET_USERS_LIST: `${BASE_URL}/users-info`,
},
USER: {
UPDATE_INFO: (id: number) => `${BASE_URL}/User/${id}`,
UPDATE_ROLE: (id: number) => `${BASE_URL}/User/${id}/role`,
},
APP_VERSION: {
//agent and app api
GET_VERSION: `${BASE_URL}/AppVersion/version`,
@ -71,11 +31,8 @@ export const API_ENDPOINTS = {
//require file api
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
},
MANIFEST: {
SEND_ALL: `${BASE_URL}/Manifest/sendall`,
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}`,
@ -125,20 +82,4 @@ export const API_ENDPOINTS = {
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
},
MESH_CENTRAL: {
GET_REMOTE_DESKTOP: (deviceId: string) =>
`${BASE_URL}/MeshCentral/devices/${encodeURIComponent(deviceId)}/remote-desktop`,
},
DASHBOARD: {
GET_SUMMARY: `${BASE_URL}/dashboard/summary`,
GET_GENERAL: `${BASE_URL}/dashboard/general`,
GET_ROOM_USAGE: `${BASE_URL}/dashboard/usage/rooms`,
GET_DEVICE_OVERVIEW: `${BASE_URL}/dashboard/devices/overview`,
GET_DEVICES_BY_ROOM: `${BASE_URL}/dashboard/devices/by-room`,
GET_ROOMS: `${BASE_URL}/dashboard/rooms`,
GET_SOFTWARE: `${BASE_URL}/dashboard/software`,
},
AUDIT: {
GET_AUDITS: `${BASE_URL}/Audit/audits`,
}
};

View File

@ -7,15 +7,9 @@ export * from "./useAppVersionQueries";
// Device Communication Queries
export * from "./useDeviceCommQueries";
// Dashboard Queries
export * from "./useDashboardQueries";
// Command Queries
export * from "./useCommandQueries";
// Audit Queries
export * from "./useAuditQueries";
// Permission Queries
export * from "./usePermissionQueries";

View File

@ -160,7 +160,7 @@ export function useDeleteRequiredFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
@ -176,7 +176,7 @@ export function useDeleteFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
@ -184,12 +184,3 @@ export function useDeleteFile() {
},
});
}
/**
* Hook đ gửi manifest
*/
export function useSendManifest() {
return useMutation({
mutationFn: () => appVersionService.sendManifest(),
});
}

View File

@ -1,37 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import * as auditService from "@/services/audit.service";
import type { PageResult, Audits } from "@/types/audit";
const AUDIT_QUERY_KEYS = {
all: ["audit"] as const,
list: () => [...AUDIT_QUERY_KEYS.all, "list"] as const,
audits: (params: any) => [...AUDIT_QUERY_KEYS.all, "audits", params] as const,
};
export function useGetAudits(
params: {
pageNumber?: number;
pageSize?: number;
username?: string | null;
action?: string | null;
from?: string | null;
to?: string | null;
} = { pageNumber: 1, pageSize: 20 },
enabled = true
) {
const { pageNumber = 1, pageSize = 20, username, action, from, to } = params;
return useQuery<PageResult<Audits>>({
queryKey: AUDIT_QUERY_KEYS.audits({ pageNumber, pageSize, username, action, from, to }),
queryFn: () =>
auditService.getAudits(
pageNumber,
pageSize,
username ?? null,
action ?? null,
from ?? null,
to ?? null
),
enabled,
});
}

View File

@ -113,19 +113,3 @@ export function useCreateAccount() {
},
});
}
/**
* Hook đ đi one-time OAuth code lấy payload đăng nhập
*/
export function useExchangeOAuthCode() {
return useMutation<LoginResponse, any, string>({
mutationFn: (code) => authService.exchangeOAuthCode(code),
});
}
/**
* Legacy alias for backward compatibility.
*/
export function useExchangeSsoCode() {
return useExchangeOAuthCode();
}

View File

@ -1,85 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import * as dashboardService from "@/services/dashboard.service";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
const DASHBOARD_QUERY_KEYS = {
all: ["dashboard"] as const,
summary: () => [...DASHBOARD_QUERY_KEYS.all, "summary"] as const,
general: () => [...DASHBOARD_QUERY_KEYS.all, "general"] as const,
roomUsage: () => [...DASHBOARD_QUERY_KEYS.all, "usage", "rooms"] as const,
deviceOverview: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "overview"] as const,
devicesByRoom: () => [...DASHBOARD_QUERY_KEYS.all, "devices", "by-room"] as const,
rooms: () => [...DASHBOARD_QUERY_KEYS.all, "rooms"] as const,
software: () => [...DASHBOARD_QUERY_KEYS.all, "software"] as const,
};
export function useGetDashboardSummary(enabled = true) {
return useQuery<DashboardSummaryResponse>({
queryKey: DASHBOARD_QUERY_KEYS.summary(),
queryFn: () => dashboardService.getDashboardSummary(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetDashboardGeneralInfo(enabled = true) {
return useQuery<DashboardGeneralInfo>({
queryKey: DASHBOARD_QUERY_KEYS.general(),
queryFn: () => dashboardService.getDashboardGeneralInfo(),
enabled,
staleTime: 60 * 1000,
});
}
export function useGetRoomUsage(enabled = true) {
return useQuery<RoomUsageResponse>({
queryKey: DASHBOARD_QUERY_KEYS.roomUsage(),
queryFn: () => dashboardService.getRoomUsage(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetDeviceOverview(enabled = true) {
return useQuery<DeviceOverviewResponse>({
queryKey: DASHBOARD_QUERY_KEYS.deviceOverview(),
queryFn: () => dashboardService.getDeviceOverview(),
enabled,
staleTime: 30 * 1000,
});
}
export function useGetDeviceStatusByRoom(enabled = true) {
return useQuery<DeviceStatusByRoom[]>({
queryKey: DASHBOARD_QUERY_KEYS.devicesByRoom(),
queryFn: () => dashboardService.getDeviceStatusByRoom(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetRoomManagement(enabled = true) {
return useQuery<RoomManagementResponse>({
queryKey: DASHBOARD_QUERY_KEYS.rooms(),
queryFn: () => dashboardService.getRoomManagement(),
enabled,
staleTime: 5 * 60 * 1000,
});
}
export function useGetSoftwareDistribution(enabled = true) {
return useQuery<SoftwareDistributionResponse>({
queryKey: DASHBOARD_QUERY_KEYS.software(),
queryFn: () => dashboardService.getSoftwareDistribution(),
enabled,
staleTime: 60 * 1000,
});
}

View File

@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as deviceCommService from "@/services/device-comm.service";
import type { DeviceHealthCheck } from "@/types/device";
import type { ClientFolderStatus } from "@/types/folder";
import type { Room } from "@/types/room";
const DEVICE_COMM_QUERY_KEYS = {
all: ["device-comm"] as const,
@ -30,7 +29,7 @@ export function useGetAllDevices(enabled = true) {
* Hook đ lấy danh sách phòng
*/
export function useGetRoomList(enabled = true) {
return useQuery<Room[]>({
return useQuery({
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
queryFn: () => deviceCommService.getRoomList(),
enabled,

View File

@ -1,10 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import * as userService from "@/services/user.service";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
import type { UserProfile } from "@/types/user-profile";
const USER_QUERY_KEYS = {
all: ["users"] as const,
@ -22,47 +18,3 @@ export function useGetUsersInfo(enabled = true) {
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
/**
* Hook đ cập nhật thông tin người dùng
*/
export function useUpdateUserInfo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserInfoRequest;
}) => userService.updateUserInfo(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}
/**
* Hook đ cập nhật role người dùng
*/
export function useUpdateUserRole() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
data,
}: {
id: number;
data: UpdateUserRoleRequest;
}) => userService.updateUserRole(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: USER_QUERY_KEYS.list(),
});
},
});
}

View File

@ -116,10 +116,5 @@
}
body {
@apply bg-background text-foreground;
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
}
}
.dashboard-scope {
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
}

View File

@ -14,12 +14,10 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
import { Route as AuthRemoteControlIndexRouteImport } from './routes/_auth/remote-control/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 AuthAuditsIndexRouteImport } from './routes/_auth/audits/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'
@ -28,12 +26,9 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
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 authOauthCallbackIndexRouteImport } from './routes/(auth)/oauth/callback/index'
import { Route as AuthUserRoleRoleIdIndexRouteImport } from './routes/_auth/user/role/$roleId/index'
import { Route as AuthUserEditUserNameIndexRouteImport } from './routes/_auth/user/edit/$userName/index'
import { Route as AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/index'
import { Route as AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
const AuthRoute = AuthRouteImport.update({
@ -60,11 +55,6 @@ const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
path: '/role/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRemoteControlIndexRoute = AuthRemoteControlIndexRouteImport.update({
id: '/remote-control/',
path: '/remote-control/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
id: '/device/',
path: '/device/',
@ -85,11 +75,6 @@ const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
path: '/blacklists/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
id: '/audits/',
path: '/audits/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
@ -132,22 +117,11 @@ const AuthProfileUserNameIndexRoute =
path: '/profile/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
id: '/(auth)/oauth/callback/',
path: '/oauth/callback/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
id: '/user/role/$roleId/',
path: '/user/role/$roleId/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserEditUserNameIndexRoute =
AuthUserEditUserNameIndexRouteImport.update({
id: '/user/edit/$userName/',
path: '/user/edit/$userName/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUserChangePasswordUserNameIndexRoute =
AuthUserChangePasswordUserNameIndexRouteImport.update({
id: '/user/change-password/$userName/',
@ -160,12 +134,6 @@ const AuthRoomsRoomNameFolderStatusIndexRoute =
path: '/rooms/$roomName/folder-status/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoomsRoomNameConnectIndexRoute =
AuthRoomsRoomNameConnectIndexRouteImport.update({
id: '/rooms/$roomName/connect/',
path: '/rooms/$roomName/connect/',
getParentRoute: () => AuthRoute,
} as any)
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
id: '/role/$id/edit/',
path: '/role/$id/edit/',
@ -177,26 +145,21 @@ export interface FileRoutesByFullPath {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/oauth/callback': typeof authOauthCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesByTo {
@ -204,26 +167,21 @@ export interface FileRoutesByTo {
'/login': typeof authLoginIndexRoute
'/agent': typeof AuthAgentIndexRoute
'/apps': typeof AuthAppsIndexRoute
'/audits': typeof AuthAuditsIndexRoute
'/blacklists': typeof AuthBlacklistsIndexRoute
'/commands': typeof AuthCommandsIndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/device': typeof AuthDeviceIndexRoute
'/remote-control': typeof AuthRemoteControlIndexRoute
'/role': typeof AuthRoleIndexRoute
'/rooms': typeof AuthRoomsIndexRoute
'/user': typeof AuthUserIndexRoute
'/oauth/callback': typeof authOauthCallbackIndexRoute
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
'/role/create': typeof AuthRoleCreateIndexRoute
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
'/user/create': typeof AuthUserCreateIndexRoute
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRoutesById {
@ -233,26 +191,21 @@ export interface FileRoutesById {
'/(auth)/login/': typeof authLoginIndexRoute
'/_auth/agent/': typeof AuthAgentIndexRoute
'/_auth/apps/': typeof AuthAppsIndexRoute
'/_auth/audits/': typeof AuthAuditsIndexRoute
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
'/_auth/commands/': typeof AuthCommandsIndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/device/': typeof AuthDeviceIndexRoute
'/_auth/remote-control/': typeof AuthRemoteControlIndexRoute
'/_auth/role/': typeof AuthRoleIndexRoute
'/_auth/rooms/': typeof AuthRoomsIndexRoute
'/_auth/user/': typeof AuthUserIndexRoute
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
'/_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/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
}
export interface FileRouteTypes {
@ -262,26 +215,21 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
fileRoutesByTo: FileRoutesByTo
to:
@ -289,26 +237,21 @@ export interface FileRouteTypes {
| '/login'
| '/agent'
| '/apps'
| '/audits'
| '/blacklists'
| '/commands'
| '/dashboard'
| '/device'
| '/remote-control'
| '/role'
| '/rooms'
| '/user'
| '/oauth/callback'
| '/profile/$userName'
| '/profile/change-password'
| '/role/create'
| '/rooms/$roomName'
| '/user/create'
| '/role/$id/edit'
| '/rooms/$roomName/connect'
| '/rooms/$roomName/folder-status'
| '/user/change-password/$userName'
| '/user/edit/$userName'
| '/user/role/$roleId'
id:
| '__root__'
@ -317,26 +260,21 @@ export interface FileRouteTypes {
| '/(auth)/login/'
| '/_auth/agent/'
| '/_auth/apps/'
| '/_auth/audits/'
| '/_auth/blacklists/'
| '/_auth/commands/'
| '/_auth/dashboard/'
| '/_auth/device/'
| '/_auth/remote-control/'
| '/_auth/role/'
| '/_auth/rooms/'
| '/_auth/user/'
| '/(auth)/oauth/callback/'
| '/_auth/profile/$userName/'
| '/_auth/profile/change-password/'
| '/_auth/role/create/'
| '/_auth/rooms/$roomName/'
| '/_auth/user/create/'
| '/_auth/role/$id/edit/'
| '/_auth/rooms/$roomName/connect/'
| '/_auth/rooms/$roomName/folder-status/'
| '/_auth/user/change-password/$userName/'
| '/_auth/user/edit/$userName/'
| '/_auth/user/role/$roleId/'
fileRoutesById: FileRoutesById
}
@ -344,7 +282,6 @@ export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
authLoginIndexRoute: typeof authLoginIndexRoute
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
}
declare module '@tanstack/react-router' {
@ -384,13 +321,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRoleIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/remote-control/': {
id: '/_auth/remote-control/'
path: '/remote-control'
fullPath: '/remote-control'
preLoaderRoute: typeof AuthRemoteControlIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/device/': {
id: '/_auth/device/'
path: '/device'
@ -419,13 +349,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/audits/': {
id: '/_auth/audits/'
path: '/audits'
fullPath: '/audits'
preLoaderRoute: typeof AuthAuditsIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/apps/': {
id: '/_auth/apps/'
path: '/apps'
@ -482,13 +405,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/(auth)/oauth/callback/': {
id: '/(auth)/oauth/callback/'
path: '/oauth/callback'
fullPath: '/oauth/callback'
preLoaderRoute: typeof authOauthCallbackIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/user/role/$roleId/': {
id: '/_auth/user/role/$roleId/'
path: '/user/role/$roleId'
@ -496,13 +412,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/edit/$userName/': {
id: '/_auth/user/edit/$userName/'
path: '/user/edit/$userName'
fullPath: '/user/edit/$userName'
preLoaderRoute: typeof AuthUserEditUserNameIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/user/change-password/$userName/': {
id: '/_auth/user/change-password/$userName/'
path: '/user/change-password/$userName'
@ -517,13 +426,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rooms/$roomName/connect/': {
id: '/_auth/rooms/$roomName/connect/'
path: '/rooms/$roomName/connect'
fullPath: '/rooms/$roomName/connect'
preLoaderRoute: typeof AuthRoomsRoomNameConnectIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/role/$id/edit/': {
id: '/_auth/role/$id/edit/'
path: '/role/$id/edit'
@ -537,12 +439,10 @@ declare module '@tanstack/react-router' {
interface AuthRouteChildren {
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
AuthAuditsIndexRoute: typeof AuthAuditsIndexRoute
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
AuthRemoteControlIndexRoute: typeof AuthRemoteControlIndexRoute
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
AuthUserIndexRoute: typeof AuthUserIndexRoute
@ -552,22 +452,18 @@ interface AuthRouteChildren {
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentIndexRoute: AuthAgentIndexRoute,
AuthAppsIndexRoute: AuthAppsIndexRoute,
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
AuthRemoteControlIndexRoute: AuthRemoteControlIndexRoute,
AuthRoleIndexRoute: AuthRoleIndexRoute,
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
AuthUserIndexRoute: AuthUserIndexRoute,
@ -577,12 +473,10 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
AuthRoomsRoomNameConnectIndexRoute: AuthRoomsRoomNameConnectIndexRoute,
AuthRoomsRoomNameFolderStatusIndexRoute:
AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
}
@ -592,7 +486,6 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
authLoginIndexRoute: authLoginIndexRoute,
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@ -1,124 +0,0 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { LoaderCircle } from "lucide-react";
import axios from "axios";
import { exchangeCodeByProvider } from "@/services/auth.service";
import type { LoginResponse } from "@/types/auth";
const inFlightExchanges = new Map<string, Promise<LoginResponse>>();
const consumedCodes = new Set<string>();
export const Route = createFileRoute("/(auth)/oauth/callback/")({
component: OAuthCallbackPage,
});
function OAuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
const search = Route.useSearch() as { code?: string; redirect?: string; provider?: string };
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isExchanging, setIsExchanging] = useState(false);
useEffect(() => {
const code = search.code;
const provider = (search.provider || "").toLowerCase();
if (!code) {
setErrorMessage("OAuth code is missing.");
return;
}
const exchangeId = `${provider || "auto"}:${code}`;
if (consumedCodes.has(exchangeId)) {
setErrorMessage("Mã đăng nhập đã được sử dụng. Vui lòng đăng nhập lại.");
return;
}
setErrorMessage(null);
setIsExchanging(true);
let cancelled = false;
let exchangePromise = inFlightExchanges.get(exchangeId);
if (!exchangePromise) {
exchangePromise = exchangeCodeByProvider(code, provider);
inFlightExchanges.set(exchangeId, exchangePromise);
}
exchangePromise
.then(async (data) => {
if (cancelled) return;
if (!data.token) {
setErrorMessage("OAuth response missing token.");
return;
}
consumedCodes.add(exchangeId);
localStorage.setItem("token", data.token);
localStorage.setItem("username", data.username || "");
localStorage.setItem("name", data.name || "");
localStorage.setItem("acs", (data.access ?? "").toString());
localStorage.setItem("role", data.role?.roleName || "");
localStorage.setItem("priority", String(data.role?.priority ?? "-1"));
localStorage.setItem("computersmanagement.auth.user", data.username || "");
localStorage.setItem("accesscontrol.auth.user", data.username || "");
auth.setAuthenticated(true);
auth.login(data.username || "");
await navigate({ to: search.redirect || "/dashboard" });
})
.catch((error) => {
if (cancelled) return;
if (axios.isAxiosError(error) && error.response?.status === 401) {
consumedCodes.add(exchangeId);
setErrorMessage("Mã đăng nhập đã hết hạn hoặc đã được sử dụng. Vui lòng đăng nhập lại.");
return;
}
setErrorMessage("OAuth exchange failed.");
})
.finally(() => {
inFlightExchanges.delete(exchangeId);
if (!cancelled) {
setIsExchanging(false);
}
});
return () => {
cancelled = true;
};
}, [auth, navigate, search.code, search.provider, search.redirect]);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-xl">Đang xác thực OAuth</CardTitle>
<CardDescription>Vui lòng đi trong giây lát.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{isExchanging && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="w-4 h-4 animate-spin" />
Đang trao đi đăng nhập
</div>
)}
{errorMessage && (
<div className="text-destructive text-sm text-center">{errorMessage}</div>
)}
{errorMessage && (
<Link to="/login" className="w-full">
<Button className="w-full">Quay lại đăng nhập</Button>
</Link>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -7,10 +7,11 @@ import {
useUpdateAgent,
} 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 { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
import { agentColumns } from "@/components/columns/agent-column";
export const Route = createFileRoute("/_auth/agent/")({
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
component: AgentsPage,
@ -70,7 +71,26 @@ function AgentsPage() {
};
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
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ập nhật",
cell: ({ getValue }) =>
getValue()
? new Date(getValue() as string).toLocaleString("vi-VN")
: "N/A",
},
];
return (
<AppManagerTemplate<Version>
@ -78,7 +98,7 @@ function AgentsPage() {
description="Quản lý và theo dõi các phiên bản Agent"
data={versionList}
isLoading={isLoading}
columns={agentColumns}
columns={columns}
onUpload={handleUpload}
onUpdate={handleUpdate}
updateLoading={updateMutation.isPending}

View File

@ -9,13 +9,14 @@ import {
useDeleteRequiredFile,
useInstallMsi,
useDownloadFiles,
useSendManifest,
} 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 { useMemo, useState } from "react";
import { createAppsColumns } from "@/components/columns/apps-column";
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,
@ -50,12 +51,62 @@ function AppsComponent() {
const deleteRequiredFileMutation = useDeleteRequiredFile();
const sendManifestMutation = useSendManifest();
const columns = useMemo(
() => createAppsColumns(installMutation.isPending),
[installMutation.isPending]
// Cột bảng
const columns: ColumnDef<Version>[] = [
{ accessorKey: "version", header: "Phiên bản" },
{ accessorKey: "fileName", header: "Tên file" },
{
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,
@ -140,10 +191,11 @@ function AppsComponent() {
return;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
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!");
@ -154,15 +206,12 @@ function AppsComponent() {
if (!table) 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;
}
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteRequiredFileMutation.mutateAsync({ MsiFileIds });
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({});
@ -177,10 +226,12 @@ function AppsComponent() {
if (!table) return;
const selectedRows = table.getSelectedRowModel().rows;
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try {
await deleteMutation.mutateAsync({ MsiFileIds });
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({});
@ -191,21 +242,6 @@ function AppsComponent() {
}
};
const handleSendManifest = async (targets: string[]) => {
// targets ignored for now — API sendall broadcasts to all devices via MQTT
// TODO: use targets when per-room/per-device manifest API is available
try {
await sendManifestMutation.mutateAsync();
toast.success(
targets.length > 0
? `Đã gửi manifest cho ${targets.length} mục!`
: "Đã gửi manifest đến tất cả thiết bị!"
);
} catch (e) {
toast.error("Gửi manifest thất bại!");
}
};
const handleAddToRequired = async () => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
@ -250,8 +286,6 @@ function AppsComponent() {
onDeleteFromServer={handleDeleteFromServer}
onDeleteFromRequired={handleDeleteFromRequiredList}
onAddToRequired={handleAddToRequired}
onSendManifest={handleSendManifest}
sendManifestLoading={sendManifestMutation.isPending}
updateLoading={installMutation.isPending}
downloadLoading={downloadMutation.isPending}
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}

View File

@ -1,92 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState, useEffect } from "react";
import { useGetAudits } from "@/hooks/queries";
import type { Audits } from "@/types/audit";
import { AuditListTemplate } from "@/template/audit-list-template";
import { auditColumns } from "@/components/columns/audit-column";
export const Route = createFileRoute("/_auth/audits/")({
head: () => ({ meta: [{ title: "Audit Logs" }] }),
loader: async ({ context }) => {
context.breadcrumbs = [{ title: "Audit logs", path: "#" }];
},
component: AuditsPage,
});
function AuditsPage() {
const [pageNumber, setPageNumber] = useState(1);
const [pageSize] = useState(20);
const [username, setUsername] = useState<string | null>(null);
const [action, setAction] = useState<string | null>(null);
const [from, setFrom] = useState<string | null>(null);
const [to, setTo] = useState<string | null>(null);
const [selectedAudit, setSelectedAudit] = useState<Audits | null>(null);
const { data, isLoading, refetch, isFetching } = useGetAudits(
{
pageNumber,
pageSize,
username,
action,
from,
to,
},
true
) as any;
const items: Audits[] = data?.items ?? [];
const total: number = data?.totalCount ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
useEffect(() => {
refetch();
}, [pageNumber, pageSize]);
const handleSearch = () => {
setPageNumber(1);
refetch();
};
const handleReset = () => {
setUsername(null);
setAction(null);
setFrom(null);
setTo(null);
setPageNumber(1);
refetch();
};
return (
<AuditListTemplate
// data
items={items}
total={total}
columns={auditColumns}
isLoading={isLoading}
isFetching={isFetching}
// pagination
pageNumber={pageNumber}
pageSize={pageSize}
pageCount={pageCount}
canPreviousPage={pageNumber > 1}
canNextPage={pageNumber < pageCount}
onPreviousPage={() => setPageNumber((p) => Math.max(1, p - 1))}
onNextPage={() => setPageNumber((p) => Math.min(pageCount, p + 1))}
// filter
username={username}
action={action}
from={from}
to={to}
onUsernameChange={setUsername}
onActionChange={setAction}
onFromChange={setFrom}
onToChange={setTo}
onSearch={handleSearch}
onReset={handleReset}
// detail dialog
selectedAudit={selectedAudit}
onRowClick={setSelectedAudit}
onDialogClose={() => setSelectedAudit(null)}
/>
);
}

View File

@ -1,9 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import {
CommandSubmitTemplate,
type SendCommandOptions,
} from "@/template/command-submit-template";
import { CommandSubmitTemplate } from "@/template/command-submit-template";
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
import {
useGetCommandList,
@ -14,6 +11,7 @@ import {
useSendCommand,
} from "@/hooks/queries";
import { toast } from "sonner";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Check, X, Edit2, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";
@ -70,18 +68,6 @@ function CommandPage() {
const deleteCommandMutation = useDeleteCommand();
const sendCommandMutation = useSendCommand();
const formInitialData = selectedCommand
? {
commandName: selectedCommand.commandName,
commandType: selectedCommand.commandType,
description: selectedCommand.description,
commandContent: selectedCommand.commandContent,
qos: selectedCommand.qoS,
isRetained: selectedCommand.isRetained,
ttlMinutes: selectedCommand.ttlMinutes ?? -1,
}
: undefined;
// Columns for command table
const columns: ColumnDef<CommandRegistry>[] = [
{
@ -107,7 +93,6 @@ function CommandPage() {
2: "SHUTDOWN",
3: "TASKKILL",
4: "BLOCK",
5: "RESET",
};
return <span>{typeMap[type] || "UNKNOWN"}</span>;
},
@ -241,10 +226,7 @@ function CommandPage() {
};
// Handle execute commands from list
const handleExecuteSelected = async (
targets: string[],
options?: SendCommandOptions
) => {
const handleExecuteSelected = async (targets: string[]) => {
if (!table) {
toast.error("Không thể lấy thông tin bảng!");
return;
@ -264,8 +246,6 @@ function CommandPage() {
Command: row.original.commandContent,
QoS: row.original.qoS,
IsRetained: row.original.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
@ -284,11 +264,7 @@ function CommandPage() {
};
// Handle execute custom command
const handleExecuteCustom = async (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => {
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
try {
for (const target of targets) {
// API expects PascalCase directly
@ -296,8 +272,6 @@ function CommandPage() {
Command: commandData.command,
QoS: commandData.qos,
IsRetained: commandData.isRetained,
TtlMinutes: options?.ttlMinutes,
SendTime: options?.sendTime,
};
await sendCommandMutation.mutateAsync({
roomName: target,
@ -330,7 +304,7 @@ function CommandPage() {
<CommandRegistryForm
onSubmit={handleFormSubmit}
closeDialog={() => setIsDialogOpen(false)}
initialData={formInitialData}
initialData={selectedCommand || undefined}
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
/>
}

View File

@ -1,75 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { DashboardTemplate } from '@/template/dashboard-template'
import {
useGetDashboardSummary,
useGetDashboardGeneralInfo,
useGetDeviceOverview,
useGetDeviceStatusByRoom,
useGetRoomUsage,
useGetRoomManagement,
useGetSoftwareDistribution,
} from '@/hooks/queries/useDashboardQueries'
export const Route = createFileRoute('/_auth/dashboard/')({
component: RouteComponent,
head: () => ({ meta: [{ title: 'Dashboard' }] }),
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Dashboard", path: "#" },
{ title: "Dashboard", path: "/_auth/dashboard/" },
];
},
})
function RouteComponent() {
const summaryQuery = useGetDashboardSummary();
const generalQuery = useGetDashboardGeneralInfo();
const deviceOverviewQuery = useGetDeviceOverview();
const devicesByRoomQuery = useGetDeviceStatusByRoom();
const roomUsageQuery = useGetRoomUsage();
const roomsQuery = useGetRoomManagement();
const softwareQuery = useGetSoftwareDistribution();
const isLoading =
summaryQuery.isLoading ||
generalQuery.isLoading ||
deviceOverviewQuery.isLoading ||
devicesByRoomQuery.isLoading ||
roomUsageQuery.isLoading ||
roomsQuery.isLoading ||
softwareQuery.isLoading;
const isFetching =
summaryQuery.isFetching ||
generalQuery.isFetching ||
deviceOverviewQuery.isFetching ||
devicesByRoomQuery.isFetching ||
roomUsageQuery.isFetching ||
roomsQuery.isFetching ||
softwareQuery.isFetching;
const handleRefresh = async () => {
await Promise.allSettled([
summaryQuery.refetch(),
generalQuery.refetch(),
deviceOverviewQuery.refetch(),
devicesByRoomQuery.refetch(),
roomUsageQuery.refetch(),
roomsQuery.refetch(),
softwareQuery.refetch(),
]);
};
return (
<DashboardTemplate
generalInfo={generalQuery.data ?? summaryQuery.data?.generalInfo}
deviceOverview={deviceOverviewQuery.data ?? summaryQuery.data?.deviceOverview}
roomManagement={roomsQuery.data ?? summaryQuery.data?.roomManagement}
roomUsage={roomUsageQuery.data ?? summaryQuery.data?.roomUsage}
softwareDistribution={softwareQuery.data ?? summaryQuery.data?.softwareDistribution}
devicesByRoom={devicesByRoomQuery.data}
isLoading={isLoading}
isFetching={isFetching}
onRefresh={handleRefresh}
/>
);
return <div>Hello "/(auth)/dashboard/"!</div>
}

View File

@ -1,149 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { LoaderCircle, Monitor, X, Maximize2 } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
export const Route = createFileRoute("/_auth/remote-control/")({
head: () => ({ meta: [{ title: "Điều khiển trực tiếp" }] }),
component: RemoteControlPage,
loader: async ({ context }) => {
context.breadcrumbs = [
{ title: "Điều khiển từ xa", path: "/_auth/remote-control/" },
{ title: "Điều khiển trực tiếp", path: "/_auth/remote-control/" },
];
},
});
function RemoteControlPage() {
const [nodeId, setNodeId] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRemote, setShowRemote] = useState(false);
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
const connectMutation = useMutation({
mutationFn: async (nodeIdValue: string) => {
// Gọi API để lấy URL remote desktop
const response = await getRemoteDesktopUrl(nodeIdValue);
return response;
},
onSuccess: (data) => {
setErrorMessage(null);
console.log("[RemoteControl] URL:", data.url);
setProxyUrl(data.url);
setShowRemote(true);
},
onError: (error: any) => {
console.error("[RemoteControl] Error:", error);
setErrorMessage(error?.response?.data?.message || "Lỗi không xác định khi kết nối remote.");
},
});
const handleConnect = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedNodeId = nodeId.trim();
if (!trimmedNodeId) {
setErrorMessage("Vui lòng nhập nodeID.");
return;
}
setErrorMessage(null);
connectMutation.mutate(trimmedNodeId);
};
const handleClose = () => {
setShowRemote(false);
setProxyUrl(null);
};
const handleFullscreen = () => {
const iframe = document.getElementById("mesh-iframe") as HTMLIFrameElement;
if (iframe?.requestFullscreen) {
iframe.requestFullscreen();
}
};
return (
<div className="w-full max-w-4xl space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Monitor className="h-5 w-5" />
Điều khiển trực tiếp
</CardTitle>
<CardDescription>
Nhập nodeID thiết bị nhấn Connect đ mở phiên remote desktop.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleConnect} className="space-y-3">
<Input
placeholder="Nhập nodeID (ví dụ: node//xxxxxx)"
value={nodeId}
onChange={(event) => setNodeId(event.target.value)}
disabled={connectMutation.isPending}
/>
<Button type="submit" disabled={connectMutation.isPending}>
{connectMutation.isPending ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang kết nối...
</>
) : (
<>
<Monitor className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
</form>
{errorMessage && (
<p className="mt-3 text-sm font-medium text-destructive">{errorMessage}</p>
)}
</CardContent>
</Card>
{showRemote && proxyUrl && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<p className="text-sm font-medium">Remote Session</p>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleFullscreen}
title="Fullscreen"
>
<Maximize2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
type="button"
onClick={handleClose}
aria-label="Đóng"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<iframe
id="mesh-iframe"
title="Remote Desktop"
src={proxyUrl}
className="h-[calc(90vh-44px)] w-full border-0"
allowFullScreen
allow="clipboard-read; clipboard-write; camera; microphone"
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,9 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/rooms/$roomName/connect/')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/_auth/rooms/$roomName/connect/"!</div>
}

View File

@ -5,7 +5,7 @@ import {
} from "@tanstack/react-router";
import { useMemo } from "react";
import { useGetClientFolderStatus } from "@/hooks/queries";
import type { ClientFolderStatus, ExtraFile, MissingFile } from "@/types/folder";
import type { ClientFolderStatus } from "@/types/folder";
import FolderStatusTemplate from "@/template/folder-status-template";
import {
createColumnHelper,
@ -36,46 +36,8 @@ function RouteComponent() {
roomName as string,
);
const sortedFolderStatusList = useMemo(() => {
return [...(folderStatusList ?? [])].sort((a, b) => {
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
if (aRoom || bRoom) {
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
numeric: true,
sensitivity: "base",
});
}
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [folderStatusList]);
const columnHelper = createColumnHelper<ClientFolderStatus>();
const renderFileList = (files?: MissingFile[] | ExtraFile[]) => {
if (!files || files.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="max-h-32 overflow-auto space-y-1">
{files.map((file) => (
<div key={`${file.folderPath}/${file.fileName}`} className="text-xs">
<div className="font-mono break-all">{file.fileName}</div>
<div className="text-muted-foreground break-all">
{file.folderPath}
</div>
</div>
))}
</div>
);
};
const columns = useMemo(
() => [
columnHelper.accessor("deviceId", {
@ -84,13 +46,14 @@ function RouteComponent() {
}),
columnHelper.display({
id: "missing",
header: "File thiếu",
cell: (info) => renderFileList(info.row.original.missingFiles),
header: "Số lượng file thiếu",
cell: (info) =>
(info.row.original.missingFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "extra",
header: "File thừa",
cell: (info) => renderFileList(info.row.original.extraFiles),
header: "Số lượng file thừa",
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
}),
columnHelper.display({
id: "current",
@ -117,7 +80,7 @@ function RouteComponent() {
);
const table = useReactTable({
data: sortedFolderStatusList,
data: folderStatusList ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
@ -125,7 +88,7 @@ function RouteComponent() {
return (
<FolderStatusTemplate
roomName={roomName as string}
data={sortedFolderStatusList}
data={folderStatusList}
isLoading={isLoading}
onBack={() =>
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)

View File

@ -1,23 +1,14 @@
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries";
import { useGetDeviceFromRoom } from "@/hooks/queries";
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
import { DeviceGrid } from "@/components/grids/device-grid";
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
import { DeviceTable } from "@/components/tables/device-table";
import { useMachineNumber } from "@/hooks/useMachineNumber";
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
import { DeviceActionBar } from "@/components/bars/device-action-bar";
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
head: ({ params }) => ({
@ -34,174 +25,69 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
function RoomDetailPage() {
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
const [viewMode, setViewMode] = useState<"grid" | "table" | "map">("map");
const [statusFilter, setStatusFilter] = useState<"all" | "on" | "off">("all");
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
// SSE real-time updates
useDeviceEvents(roomName);
// Folder status from SS
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
const { data: roomData = [] } = useGetRoomList();
const parseMachineNumber = useMachineNumber();
const navigate = useNavigate();
const sortedDevices = useMemo(() => {
return [...devices].sort((a, b) => {
const sortedDevices = [...devices].sort((a, b) => {
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
});
}, [devices, parseMachineNumber]);
const currentRoom = roomData.find((room) => room.name === roomName);
const totalSeats = currentRoom?.numberOfDevices;
const deviceCount = sortedDevices.length;
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
const onlineCount = Math.max(0, deviceCount - offlineCount);
const mapDisabled = deviceCount > 200;
const forceTable = deviceCount > 2000;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearch(searchInput.trim());
}, 300);
return () => clearTimeout(handler);
}, [searchInput]);
useEffect(() => {
setSelectedIds([]);
setLastSelectedIndex(null);
}, [roomName]);
useEffect(() => {
if (forceTable && viewMode !== "table") {
setViewMode("table");
}
}, [forceTable, viewMode]);
useEffect(() => {
if (mapDisabled && viewMode === "map") {
setViewMode("grid");
}
}, [mapDisabled, viewMode]);
const filteredDevices = useMemo(() => {
const query = debouncedSearch.toLowerCase();
return sortedDevices.filter((device) => {
if (statusFilter === "on" && device.isOffline) return false;
if (statusFilter === "off" && !device.isOffline) return false;
if (!query) return true;
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
const id = device.id ?? "";
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
return haystack.includes(query);
});
}, [sortedDevices, statusFilter, debouncedSearch]);
const filteredDeviceIds = useMemo(
() => filteredDevices.map((device) => device.id),
[filteredDevices]
);
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const selectedDevices = useMemo(
() => devices.filter((device) => selectedSet.has(device.id)),
[devices, selectedSet]
);
useEffect(() => {
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
}, [devices]);
useEffect(() => {
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
setLastSelectedIndex(null);
}
}, [filteredDeviceIds.length, lastSelectedIndex]);
const handleSelectDevice = (
deviceId: string,
index: number,
event: MouseEvent<HTMLElement>
) => {
const isShift = event.shiftKey;
setSelectedIds((prev) => {
const next = new Set(prev);
if (isShift && lastSelectedIndex !== null) {
const start = Math.min(lastSelectedIndex, index);
const end = Math.max(lastSelectedIndex, index);
const rangeIds = filteredDeviceIds.slice(start, end + 1);
rangeIds.forEach((id) => next.add(id));
return Array.from(next);
}
if (next.has(deviceId)) {
next.delete(deviceId);
} else {
next.add(deviceId);
}
return Array.from(next);
});
setLastSelectedIndex(index);
};
const handleToggleAll = (checked: boolean) => {
setSelectedIds(checked ? filteredDeviceIds : []);
setLastSelectedIndex(null);
};
const handleClearSelection = () => {
setSelectedIds([]);
setLastSelectedIndex(null);
};
const chipOptions = [
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
{ key: "on" as const, label: `On (${onlineCount})` },
{ key: "off" as const, label: `Off (${offlineCount})` },
];
return (
<div className="w-full px-6">
<div className="space-y-6">
<div className="w-full px-6 space-y-6">
<Card className="shadow-sm">
<CardHeader className="bg-muted/50 space-y-3 pb-3">
{/* Row 1: Title + stats */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2 shrink-0">
<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>Phòng {roomName}</CardTitle>
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
</div>
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[11px] text-teal-700 border-teal-200 bg-teal-50">
On {onlineCount}
</Badge>
<Badge variant="outline" className="text-[11px] text-slate-600 border-slate-200 bg-slate-50">
Off {offlineCount}
</Badge>
<Badge variant="secondary" className="text-[11px]">
Tổng {deviceCount}
</Badge>
<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>
{/* Row 2: Command buttons + folder button cùng hàng */}
{/* 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 justify-end">
{/* Command Action Buttons */}
{devices.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<>
<CommandActionButtons roomName={roomName} />
<div className="h-5 w-px bg-border shrink-0" />
<div className="h-8 w-px bg-border" />
<Button
onClick={() =>
navigate({
@ -216,87 +102,8 @@ function RoomDetailPage() {
<FolderCheck className="h-4 w-4" />
Kiểm tra thư mục Setup
</Button>
</div>
</>
)}
{/* Row 3: View toggle + search + filter */}
<div className="flex flex-wrap items-center gap-2">
{/* View mode */}
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
<Button
variant={viewMode === "grid" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("grid")}
className="h-7 gap-1.5 px-2.5 text-xs"
disabled={forceTable}
>
<LayoutGrid className="h-3.5 w-3.5" />
Lưới
</Button>
<Button
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("table")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<TableIcon className="h-3.5 w-3.5" />
Bảng
</Button>
{mapDisabled || forceTable ? (
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2.5 text-xs"
disabled
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
đ chỉ hỗ trợ phòng &lt;= 200 thiết bị.
</TooltipContent>
</Tooltip>
) : (
<Button
variant={viewMode === "map" ? "default" : "ghost"}
size="sm"
onClick={() => setViewMode("map")}
className="h-7 gap-1.5 px-2.5 text-xs"
>
<Monitor className="h-3.5 w-3.5" />
đ
</Button>
)}
</div>
{/* Search */}
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tìm theo số máy, IP hoặc mã thiết bị"
className="h-8 w-56 shrink-0"
/>
{/* Status filter */}
<div className="flex items-center gap-1 ml-auto">
{chipOptions.map((chip) => (
<Button
key={chip.key}
variant={statusFilter === chip.key ? "default" : "outline"}
size="sm"
className="h-8"
onClick={() => setStatusFilter(chip.key)}
>
{chip.label}
</Button>
))}
</div>
</div>
</CardHeader>
@ -310,64 +117,18 @@ function RoomDetailPage() {
Phòng này chưa thiết bị nào đưc kết nối.
</p>
</div>
) : (
<div className="space-y-4 p-4">
{forceTable && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
Phòng này {deviceCount} thiết bị. Chỉ hiển thị chế đ
Bảng đ đm bảo hiệu năng.
</div>
)}
{!forceTable && viewMode === "grid" && deviceCount > 500 && (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
Phòng này nhiều thiết bị. Bạn thể chuyển sang chế đ
Bảng đ thao tác nhanh hơn.
</div>
)}
{filteredDevices.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<Monitor className="h-10 w-10 text-muted-foreground mb-3" />
<h3 className="text-base font-semibold mb-1">
Không thiết bị phù hợp
</h3>
<p className="text-muted-foreground text-center max-w-sm">
Hãy thử thay đi từ khóa hoặc bộ lọc trạng thái.
</p>
</div>
) : forceTable || viewMode === "table" ? (
<DeviceTable
devices={filteredDevices}
selectedIds={selectedIds}
onToggleDevice={handleSelectDevice}
onToggleAll={handleToggleAll}
/>
) : viewMode === "map" ? (
) : viewMode === "grid" ? (
<DeviceGrid
devices={filteredDevices}
totalSeats={totalSeats}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
devices={sortedDevices}
/>
) : (
<DeviceGridCompact
devices={filteredDevices}
selectedIds={selectedIds}
onSelectDevice={handleSelectDevice}
<DeviceTable
devices={sortedDevices}
/>
)}
</div>
)}
</CardContent>
</Card>
<DeviceActionBar
roomName={roomName}
selectedDevices={selectedDevices}
onClearSelection={handleClearSelection}
/>
</div>
</div>
);
}

View File

@ -9,9 +9,6 @@ import { LoaderCircle } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
head: () => ({
meta: [{ title: "Thay đổi mật khẩu" }],
}),
component: AdminChangePasswordComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [

View File

@ -22,9 +22,6 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/create/")({
head: () => ({
meta: [{ title: "Tạo người dùng mới" }],
}),
component: CreateUserComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -62,8 +59,7 @@ function CreateUserComponent() {
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ự)";
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
@ -110,8 +106,7 @@ function CreateUserComponent() {
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!";
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage);
}
};
@ -133,14 +128,15 @@ function CreateUserComponent() {
{/* 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>
<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: "/user" })}>
<Button
variant="outline"
onClick={() => navigate({ to: "/user" })}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
@ -168,9 +164,7 @@ function CreateUserComponent() {
<Input
id="userName"
value={formData.userName}
onChange={(e) =>
handleInputChange("userName", e.target.value)
}
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"
@ -208,9 +202,7 @@ function CreateUserComponent() {
id="password"
type="password"
value={formData.password}
onChange={(e) =>
handleInputChange("password", e.target.value)
}
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"
@ -228,17 +220,13 @@ function CreateUserComponent() {
id="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
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>
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
)}
</div>
</div>

View File

@ -1,361 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import {
useGetRoleList,
useGetRoomList,
useGetUsersInfo,
useUpdateUserInfo,
useUpdateUserRole,
} from "@/hooks/queries";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { ArrowLeft, Save } from "lucide-react";
import { toast } from "sonner";
import type { UserProfile } from "@/types/user-profile";
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
head: () => ({
meta: [{ title: "Chỉnh sửa người dùng" }],
}),
component: EditUserComponent,
loader: async ({ context, params }) => {
context.breadcrumbs = [
{ title: "Quản lý người dùng", path: "/user" },
{
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
path: `/user/edit/${params.userName}`,
},
];
},
});
function EditUserComponent() {
const { userName } = Route.useParams();
const navigate = useNavigate();
const { data: users = [], isLoading } = useGetUsersInfo();
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
const updateUserInfoMutation = useUpdateUserInfo();
const updateUserRoleMutation = useUpdateUserRole();
const user = useMemo(() => {
return users.find((u) => u.userName === userName) as
| UserProfile
| undefined;
}, [users, userName]);
const [editForm, setEditForm] = useState({
userName: "",
name: "",
});
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
const roomOptions = useMemo(() => {
const list = Array.isArray(roomData) ? roomData : [];
return list
.map((room: any) => {
const rawValue =
room.id ??
room.roomId ??
room.roomID ??
room.Id ??
room.ID ??
room.RoomId ??
room.RoomID ??
room.name ??
room.roomName ??
room.RoomName ??
"";
const label =
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
if (!rawValue || !label) return null;
return { label: String(label), value: String(rawValue) };
})
.filter((item): item is { label: string; value: string } => !!item);
}, [roomData]);
const roomLabelMap = useMemo(() => {
return new Map(roomOptions.map((room) => [room.value, room.label]));
}, [roomOptions]);
useEffect(() => {
if (!user) return;
setEditForm({
userName: user.userName ?? "",
name: user.name ?? "",
});
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
setSelectedRoomValues(
Array.isArray(user.accessRooms)
? user.accessRooms.map((roomId) => String(roomId))
: []
);
}, [user]);
const handleUpdateUserInfo = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật.");
return;
}
const nextUserName = editForm.userName.trim();
const nextName = editForm.name.trim();
if (!nextUserName || !nextName) {
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
return;
}
try {
const accessRooms = selectedRoomValues
.map((value) => Number(value))
.filter((value) => Number.isFinite(value));
if (
selectedRoomValues.length > 0 &&
accessRooms.length !== selectedRoomValues.length
) {
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
return;
}
await updateUserInfoMutation.mutateAsync({
id: user.userId,
data: {
userName: nextUserName,
name: nextName,
accessRooms,
},
});
toast.success("Cập nhật thông tin người dùng thành công!");
} catch (error: any) {
const message = error?.response?.data?.message || "Cập nhật thất bại!";
toast.error(message);
}
};
const handleUpdateUserRole = async () => {
if (!user?.userId) {
toast.error("Không tìm thấy userId để cập nhật role.");
return;
}
if (!selectedRoleId) {
toast.error("Vui lòng chọn vai trò.");
return;
}
try {
await updateUserRoleMutation.mutateAsync({
id: user.userId,
data: { roleId: Number(selectedRoleId) },
});
toast.success("Cập nhật vai trò thành công!");
} catch (error: any) {
const message =
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
toast.error(message);
}
};
if (isLoading) {
return (
<div className="w-full px-6 py-8">
<div className="flex items-center justify-center min-h-[320px]">
<div className="text-muted-foreground">
Đang tải thông tin người dùng...
</div>
</div>
</div>
);
}
if (!user) {
return (
<div className="w-full px-6 py-8 space-y-4">
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
<div className="text-muted-foreground">
Không tìm thấy người dùng cần chỉnh sửa.
</div>
</div>
);
}
return (
<div className="w-full px-6 py-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Chỉnh sửa người dùng
</h1>
<p className="text-muted-foreground mt-1">
Tài khoản: {user.userName}
</p>
</div>
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại
</Button>
</div>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Thông tin người dùng</CardTitle>
<CardDescription>
Cập nhật họ tên, username danh sách phòng truy cập.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
<Input
id="edit-userName"
value={editForm.userName}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-name">Họ tên</Label>
<Input
id="edit-name"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
disabled={updateUserInfoMutation.isPending}
/>
</div>
</div>
<div className="space-y-2">
<Label>Các phòng phụ trách</Label>
<div className="flex flex-wrap gap-2">
{selectedRoomValues.length > 0 ? (
selectedRoomValues.map((value) => (
<Badge key={value} variant="secondary">
{roomLabelMap.get(value) ?? value}
</Badge>
))
) : (
<span className="text-xs text-muted-foreground">
Chưa chọn phòng.
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsRoomDialogOpen(true)}
disabled={roomsLoading || updateUserInfoMutation.isPending}
>
Chọn phòng
</Button>
{roomsLoading && (
<span className="text-xs text-muted-foreground">
Đang tải danh sách phòng...
</span>
)}
</div>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserInfo}
disabled={updateUserInfoMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserInfoMutation.isPending
? "Đang lưu..."
: "Lưu thông tin"}
</Button>
</div>
</CardContent>
</Card>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Vai trò</CardTitle>
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2 max-w-md">
<Label>Vai trò</Label>
<Select
value={selectedRoleId}
onValueChange={setSelectedRoleId}
disabled={rolesLoading || updateUserRoleMutation.isPending}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
}
/>
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={String(role.id)}>
{role.roleName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end">
<Button
type="button"
onClick={handleUpdateUserRole}
disabled={updateUserRoleMutation.isPending || rolesLoading}
className="gap-2"
>
<Save className="h-4 w-4" />
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
</Button>
</div>
</CardContent>
</Card>
<SelectDialog
open={isRoomDialogOpen}
onClose={() => setIsRoomDialogOpen(false)}
title="Chọn phòng phụ trách"
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
items={roomOptions}
selectedValues={selectedRoomValues}
onConfirm={(values) => setSelectedRoomValues(values)}
/>
</div>
);
}

View File

@ -10,13 +10,10 @@ import {
} from "@/components/ui/tooltip";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
import { Edit2, Trash2, Shield } from "lucide-react";
import { toast } from "sonner";
export const Route = createFileRoute("/_auth/user/")({
head: () => ({
meta: [{ title: "Danh sách người dùng" }],
}),
component: RouteComponent,
loader: async ({ context }) => {
context.breadcrumbs = [
@ -68,6 +65,21 @@ function RouteComponent() {
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
),
},
{
id: "select",
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
cell: ({ row }) => (
<div className="flex justify-center">
<input
type="checkbox"
checked={row.getIsSelected?.() ?? false}
onChange={row.getToggleSelectedHandler?.()}
/>
</div>
),
enableSorting: false,
enableHiding: false,
},
{
id: "actions",
header: () => (
@ -75,26 +87,6 @@ function RouteComponent() {
),
cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/edit/$userName",
params: { userName: row.original.userName },
} as any);
}}
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi thông tin</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -108,29 +100,16 @@ function RouteComponent() {
>
<Edit2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Đi mật khẩu</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/role/$roleId",
params: { roleId: String(row.original.roleId) },
} as any);
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
}}
>
<Shield className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xem quyền</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
@ -144,9 +123,6 @@ function RouteComponent() {
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Xóa người dùng</TooltipContent>
</Tooltip>
</div>
),
enableSorting: false,

View File

@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
head: () => ({
meta: [{ title: "Quyền của người dùng" }]
meta: [{ title: "Quyền của người dùng | AccessControl" }]
}),
component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => {

View File

@ -108,32 +108,20 @@ export async function addRequiredFile(data: any): Promise<{ message: string }> {
/**
* Xóa file bắt buộc
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
* @param fileId - ID file
*/
export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
const response = await axios.post(
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE,
data
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
);
return response.data;
}
/**
* Xóa file từ server
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
* @param fileId - ID file
*/
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
{ data }
);
return response.data;
}
/**
* Gửi manifest (build + publish MQTT tới tất cả required files)
*/
export async function sendManifest(): Promise<{ status: string; message: string }> {
const response = await axios.post(API_ENDPOINTS.MANIFEST.SEND_ALL);
export async function deleteFile(fileId: number): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
return response.data;
}

View File

@ -1,19 +0,0 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { PageResult, Audits } from "@/types/audit";
export async function getAudits(
pageNumber = 1,
pageSize = 20,
username?: string | null,
action?: string | null,
from?: string | null,
to?: string | null
): Promise<PageResult<Audits>> {
const response = await axios.get<PageResult<Audits>>(API_ENDPOINTS.AUDIT.GET_AUDITS, {
params: { pageNumber, pageSize, username, action, from, to },
});
// API trả về camelCase khớp với PageResult<Audits> — dùng trực tiếp, không cần map
return response.data;
}

View File

@ -1,6 +1,5 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import rawAxios from "axios";
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
/**
@ -16,96 +15,6 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
return response.data;
}
/**
* Build OAuth login URL by provider
* @param provider - OAuth provider key (e.g. google, azuread)
* @param returnUrl - FE callback url
*/
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Build Google OAuth login URL
* @param returnUrl - FE callback url
*/
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
return buildOAuthLoginUrl("google", returnUrl);
}
/**
* Build Microsoft SSO login URL (legacy endpoint)
* @param returnUrl - FE callback url
*/
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
const encoded = encodeURIComponent(returnUrl);
return `${base}?returnUrl=${encoded}`;
}
/**
* Exchange one-time OAuth code for login payload
* @param code - one-time code
*/
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
try {
const response = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
{ code }
);
return response.data;
} catch (error) {
if (rawAxios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401 || status === 404 || status === 405) {
const fallbackResponse = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return fallbackResponse.data;
}
}
throw error;
}
}
/**
* Legacy AzureAD SSO URL builder kept for backward compatibility.
*/
export function buildSsoLoginUrl(returnUrl: string): string {
return buildOAuthLoginUrl("azuread", returnUrl);
}
/**
* Legacy SSO exchange alias kept for backward compatibility.
*/
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
return exchangeOAuthCode(code);
}
/**
* Exchange one-time code by provider without breaking existing flows.
* - azuread/microsoft: force legacy SSO exchange endpoint
* - default: use OAuth exchange flow
*/
export async function exchangeCodeByProvider(
code: string,
provider?: string
): Promise<LoginResponse> {
const providerKey = (provider || "").toLowerCase();
if (providerKey === "microsoft" || providerKey === "azuread") {
const response = await rawAxios.post<LoginResponse>(
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
{ code }
);
return response.data;
}
return exchangeOAuthCode(code);
}
/**
* Đăng xuất
*/

View File

@ -1,46 +0,0 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type {
DashboardSummaryResponse,
DashboardGeneralInfo,
DeviceOverviewResponse,
DeviceStatusByRoom,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
} from "@/types/dashboard";
export async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
const response = await axios.get<DashboardSummaryResponse>(API_ENDPOINTS.DASHBOARD.GET_SUMMARY);
return response.data;
}
export async function getDashboardGeneralInfo(): Promise<DashboardGeneralInfo> {
const response = await axios.get<DashboardGeneralInfo>(API_ENDPOINTS.DASHBOARD.GET_GENERAL);
return response.data;
}
export async function getRoomUsage(): Promise<RoomUsageResponse> {
const response = await axios.get<RoomUsageResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOM_USAGE);
return response.data;
}
export async function getDeviceOverview(): Promise<DeviceOverviewResponse> {
const response = await axios.get<DeviceOverviewResponse>(API_ENDPOINTS.DASHBOARD.GET_DEVICE_OVERVIEW);
return response.data;
}
export async function getDeviceStatusByRoom(): Promise<DeviceStatusByRoom[]> {
const response = await axios.get<DeviceStatusByRoom[]>(API_ENDPOINTS.DASHBOARD.GET_DEVICES_BY_ROOM);
return response.data;
}
export async function getRoomManagement(): Promise<RoomManagementResponse> {
const response = await axios.get<RoomManagementResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOMS);
return response.data;
}
export async function getSoftwareDistribution(): Promise<SoftwareDistributionResponse> {
const response = await axios.get<SoftwareDistributionResponse>(API_ENDPOINTS.DASHBOARD.GET_SOFTWARE);
return response.data;
}

View File

@ -1,7 +1,6 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type { DeviceHealthCheck } from "@/types/device";
import type { Room } from "@/types/room";
/**
* Lấy tất cả thiết bị trong hệ thống
@ -14,8 +13,8 @@ export async function getAllDevices(): Promise<any[]> {
/**
* Lấy danh sách phòng
*/
export async function getRoomList(): Promise<Room[]> {
const response = await axios.get<Room[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
export async function getRoomList(): Promise<any[]> {
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
return response.data;
}

View File

@ -18,9 +18,3 @@ export * as roleService from "./role.service";
// Mesh Central API Services
export * as meshCentralService from "./meshcentral.service";
// Dashboard API Services
export * as dashboardService from "./dashboard.service";
// Remote Control API Services
export * as remoteControlService from "./remote-control.service";

View File

@ -1,13 +0,0 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
export type RemoteDesktopResponse = {
url: string;
};
export async function getRemoteDesktopUrl(nodeId: string): Promise<RemoteDesktopResponse> {
const response = await axios.get<RemoteDesktopResponse>(
API_ENDPOINTS.MESH_CENTRAL.GET_REMOTE_DESKTOP(nodeId.trim())
);
return response.data;
}

View File

@ -1,20 +1,6 @@
import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api";
import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
UpdateUserInfoResponse,
UpdateUserRoleResponse,
} from "@/types/user-profile";
// Helper to extract data from wrapped or unwrapped response
function extractData<T>(responseData: any): T {
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
return responseData.data as T;
}
return responseData as T;
}
import type { UserProfile } from "@/types/user-profile";
/**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys
@ -25,7 +11,6 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
const list = Array.isArray(response.data) ? response.data : [];
return list.map((u: any) => ({
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
userName: u.userName ?? u.UserName ?? "",
name: u.name ?? u.Name ?? "",
role: u.role ?? u.Role ?? "",
@ -46,32 +31,4 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
}
}
/**
* Cập nhật thông tin người dùng
*/
export async function updateUserInfo(
userId: number,
data: UpdateUserInfoRequest
): Promise<UpdateUserInfoResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_INFO(userId),
data
);
return extractData<UpdateUserInfoResponse>(response.data);
}
/**
* Cập nhật role người dùng
*/
export async function updateUserRole(
userId: number,
data: UpdateUserRoleRequest
): Promise<UpdateUserRoleResponse> {
const response = await axios.put(
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
data
);
return extractData<UpdateUserRoleResponse>(response.data);
}
export default { getUsersInfo, updateUserInfo, updateUserRole };
export default { getUsersInfo };

View File

@ -14,7 +14,7 @@ import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
import { DeleteMenu } from "@/components/menu/delete-menu";
import { Button } from "@/components/ui/button";
import type { AxiosProgressEvent } from "axios";
import { useMemo, useState } from "react";
import { useState } from "react";
import { SelectDialog } from "@/components/dialogs/select-dialog";
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
import { UploadVersionForm } from "@/components/forms/upload-file-form";
@ -43,8 +43,6 @@ interface AppManagerTemplateProps<TData> {
deleteLoading?: boolean;
onAddToRequired?: () => Promise<void> | void;
addToRequiredLoading?: boolean;
onSendManifest?: (targetNames: string[]) => Promise<void> | void;
sendManifestLoading?: boolean;
onTableInit?: (table: any) => void;
rooms?: Room[];
devices?: string[];
@ -72,8 +70,6 @@ export function AppManagerTemplate<TData>({
deleteLoading,
onAddToRequired,
addToRequiredLoading,
onSendManifest,
sendManifestLoading,
onTableInit,
rooms = [],
devices = [],
@ -82,23 +78,7 @@ export function AppManagerTemplate<TData>({
pageSizeOptions = [5, 10, 15, 20],
}: AppManagerTemplateProps<TData>) {
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | "manifest-room" | "manifest-device" | null>(null);
const sortedData = useMemo(() => {
const firstItem = data?.[0] as { fileName?: string } | undefined;
if (!firstItem || typeof firstItem.fileName !== "string") {
return data;
}
return [...data].sort((a, b) => {
const aName = (a as { fileName?: string }).fileName ?? "";
const bName = (b as { fileName?: string }).fileName ?? "";
return aName.localeCompare(bName, "vi", {
numeric: true,
sensitivity: "base",
});
});
}, [data]);
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
const openRoomDialog = () => {
if (rooms.length > 0 && onUpdate) {
@ -128,26 +108,6 @@ export function AppManagerTemplate<TData>({
}
};
const openManifestRoomDialog = () => {
if (rooms.length > 0 && onSendManifest) {
setDialogType("manifest-room");
setDialogOpen(true);
}
};
const openManifestDeviceDialog = () => {
if (onSendManifest) {
setDialogType("manifest-device");
setDialogOpen(true);
}
};
const handleManifestAll = async () => {
if (!onSendManifest) return;
await onSendManifest([]);
};
const handleUpdateAll = async () => {
if (!onUpdate) return;
try {
@ -189,7 +149,7 @@ export function AppManagerTemplate<TData>({
<CardContent>
<VersionTable
data={sortedData}
data={data}
isLoading={isLoading}
columns={columns}
onTableInit={onTableInit}
@ -241,18 +201,6 @@ export function AppManagerTemplate<TData>({
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
</Button>
)}
{onSendManifest && (
<RequestUpdateMenu
onUpdateAll={handleManifestAll}
onUpdateRoom={openManifestRoomDialog}
onUpdateDevice={openManifestDeviceDialog}
loading={sendManifestLoading}
label="Gửi Manifest"
allLabel="Gửi tất cả"
roomLabel="Gửi theo phòng"
deviceLabel="Gửi theo thiết bị"
/>
)}
</div>
{onDeleteFromServer && onDeleteFromRequired && (
<DeleteMenu
@ -351,7 +299,6 @@ export function AppManagerTemplate<TData>({
/>
)}
{/* Dialog tải file - tìm thiết bị */}
{dialogType === "download-device" && (
<DeviceSearchDialog
@ -381,60 +328,6 @@ export function AppManagerTemplate<TData>({
}}
/>
)}
{/* Dialog gửi manifest - chọn phòng */}
{dialogType === "manifest-room" && (
<SelectDialog
open={dialogOpen}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
title="Chọn phòng"
description="Chọn các phòng để gửi manifest"
icon={<Building2 className="w-6 h-6 text-primary" />}
items={mapRoomsToSelectItems(rooms)}
onConfirm={async (selectedItems) => {
if (!onSendManifest) return;
try {
await onSendManifest(selectedItems);
} catch (e) {
console.error("Send manifest error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
}
}}
/>
)}
{/* Dialog gửi manifest - chọn thiết bị */}
{dialogType === "manifest-device" && (
<DeviceSearchDialog
open={dialogOpen && dialogType === "manifest-device"}
onClose={() => {
setDialogOpen(false);
setDialogType(null);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onSendManifest) {
setDialogOpen(false);
setDialogType(null);
return;
}
try {
await onSendManifest(deviceIds);
} catch (e) {
console.error("Send manifest error:", e);
} finally {
setDialogOpen(false);
setDialogType(null);
}
}}
/>
)}
</div>
);
}

View File

@ -1,242 +0,0 @@
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
} from "@/components/ui/table";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { type Audits } from "@/types/audit";
import { AuditFilterBar } from "@/components/filters/audit-filter-bar";
import { AuditDetailDialog } from "@/components/dialogs/audit-detail-dialog";
interface AuditListTemplateProps {
// data
items: Audits[];
total: number;
columns: ColumnDef<Audits>[];
isLoading: boolean;
isFetching: boolean;
// pagination
pageNumber: number;
pageSize: number;
pageCount: number;
onPreviousPage: () => void;
onNextPage: () => void;
canPreviousPage: boolean;
canNextPage: boolean;
// filter
username: string | null;
action: string | null;
from: string | null;
to: string | null;
onUsernameChange: (v: string | null) => void;
onActionChange: (v: string | null) => void;
onFromChange: (v: string | null) => void;
onToChange: (v: string | null) => void;
onSearch: () => void;
onReset: () => void;
// detail dialog
selectedAudit: Audits | null;
onRowClick: (audit: Audits) => void;
onDialogClose: () => void;
}
export function AuditListTemplate({
items,
total,
columns,
isLoading,
isFetching,
pageNumber,
pageSize,
pageCount,
onPreviousPage,
onNextPage,
canPreviousPage,
canNextPage,
username,
action,
from,
to,
onUsernameChange,
onActionChange,
onFromChange,
onToChange,
onSearch,
onReset,
selectedAudit,
onRowClick,
onDialogClose,
}: AuditListTemplateProps) {
const table = useReactTable({
data: items,
columns,
state: {
pagination: { pageIndex: Math.max(0, pageNumber - 1), pageSize },
},
pageCount,
manualPagination: true,
onPaginationChange: (updater) => {
const next =
typeof updater === "function"
? updater({ pageIndex: Math.max(0, pageNumber - 1), pageSize })
: updater;
const newPage = (next.pageIndex ?? 0) + 1;
if (newPage > pageNumber) onNextPage();
else if (newPage < pageNumber) onPreviousPage();
},
getCoreRowModel: getCoreRowModel(),
});
return (
<div className="w-full px-4 md:px-6 space-y-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold">Nhật hoạt đng</h1>
<p className="text-muted-foreground mt-1 text-sm">
Xem nhật audit hệ thống
</p>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Danh sách audit</CardTitle>
<CardDescription className="text-xs">
Lọc theo người dùng, loại, hành đng khoảng thời gian. Nhấn vào
dòng đ xem chi tiết.
</CardDescription>
</CardHeader>
<CardContent>
<AuditFilterBar
username={username}
action={action}
from={from}
to={to}
isLoading={isLoading}
isFetching={isFetching}
onUsernameChange={onUsernameChange}
onActionChange={onActionChange}
onFromChange={onFromChange}
onToChange={onToChange}
onSearch={onSearch}
onReset={onReset}
/>
<div className="rounded-md border overflow-x-auto">
<Table className="min-w-[640px] w-full">
<TableHeader className="sticky top-0 bg-background z-10">
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((header) => (
<TableHead
key={header.id}
className="text-xs font-semibold whitespace-nowrap"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading || isFetching ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Đang tải...
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-10 text-muted-foreground text-sm"
>
Không dữ liệu
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="hover:bg-muted/40 cursor-pointer"
onClick={() => onRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2.5 align-middle">
{cell.column.columnDef.cell
? flexRender(
cell.column.columnDef.cell,
cell.getContext()
)
: String(cell.getValue() ?? "")}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between mt-4">
<span className="text-xs text-muted-foreground">
Hiển thị {items.length} / {total} mục
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={!canPreviousPage || isFetching}
onClick={onPreviousPage}
>
Trước
</Button>
<span className="text-sm tabular-nums">
{pageNumber} / {pageCount}
</span>
<Button
variant="outline"
size="sm"
disabled={!canNextPage || isFetching}
onClick={onNextPage}
>
Sau
</Button>
</div>
</div>
</CardContent>
</Card>
<AuditDetailDialog
audit={selectedAudit}
open={!!selectedAudit}
onClose={onDialogClose}
/>
</div>
);
}

View File

@ -11,13 +11,9 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ColumnDef } from "@tanstack/react-table";
import { VersionTable } from "@/components/tables/version-table";
import {
@ -32,11 +28,6 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
import type { Room } from "@/types/room";
import { toast } from "sonner";
export interface SendCommandOptions {
ttlMinutes?: number;
sendTime?: Date;
}
interface CommandSubmitTemplateProps<T extends { id: number }> {
title: string;
description: string;
@ -60,15 +51,8 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
onTableInit?: (table: any) => void;
// Execute
onExecuteSelected?: (
targets: string[],
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteCustom?: (
targets: string[],
commandData: ShellCommandData,
options?: SendCommandOptions
) => void | Promise<void>;
onExecuteSelected?: (targets: string[]) => void;
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
isExecuting?: boolean;
// Execution scope
@ -129,158 +113,17 @@ export function CommandSubmitTemplate<T extends { id: number }>({
const [customCommand, setCustomCommand] = useState("");
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
const [customRetained, setCustomRetained] = useState(false);
const [table, setTable] = useState<any>();
const [dialogOpen2, setDialogOpen2] = useState(false);
const [dialogType, setDialogType] = useState<
"room" | "device" | "room-custom" | "device-custom" | null
>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
const [sendTimeInput, setSendTimeInput] = useState("");
const [confirmError, setConfirmError] = useState<string | null>(null);
const [pendingAction, setPendingAction] = useState<
| { type: "selected"; targets: string[] }
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
| null
>(null);
const handleTableInit = (t: any) => {
setTable(t);
onTableInit?.(t);
};
const closeTargetDialog = () => {
setDialogOpen2(false);
setDialogType(null);
};
const resetConfirmState = () => {
setConfirmOpen(false);
setPendingAction(null);
setTtlMinutesInput("");
setSendTimeInput("");
setConfirmError(null);
};
const formatLocalSendTime = (date: Date) => {
const pad2 = (value: number) => String(value).padStart(2, "0");
const hh = pad2(date.getHours());
const mm = pad2(date.getMinutes());
const ss = pad2(date.getSeconds());
const dd = pad2(date.getDate());
const MM = pad2(date.getMonth() + 1);
const yy = pad2(date.getFullYear() % 100);
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
};
const openConfirmForSelected = (targets: string[]) => {
setPendingAction({ type: "selected", targets });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const openConfirmForCustom = (
targets: string[],
commandData: ShellCommandData
) => {
setPendingAction({ type: "custom", targets, commandData });
setSendTimeInput(formatLocalSendTime(new Date()));
setConfirmOpen(true);
setConfirmError(null);
};
const parseSendOptions = (): {
options?: SendCommandOptions;
error?: string;
} => {
const options: SendCommandOptions = {};
const ttlTrimmed = ttlMinutesInput.trim();
if (ttlTrimmed) {
const parsedTtl = Number(ttlTrimmed);
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
return { error: "TtlMinutes phải là số nguyên >= 0." };
}
options.ttlMinutes = parsedTtl;
}
const sendTrimmed = sendTimeInput.trim();
if (sendTrimmed) {
const match =
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
sendTrimmed
);
if (!match) {
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
}
const [, hh, mm, ss, dd, MM, yy] = match;
const hour = Number(hh);
const minute = Number(mm);
const second = Number(ss);
const day = Number(dd);
const month = Number(MM);
const year = 2000 + Number(yy);
if (
hour > 23 ||
minute > 59 ||
second > 59 ||
month < 1 ||
month > 12 ||
day < 1 ||
day > 31
) {
return { error: "SendTime không hợp lệ." };
}
const date = new Date(year, month - 1, day, hour, minute, second);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day ||
date.getHours() !== hour ||
date.getMinutes() !== minute ||
date.getSeconds() !== second
) {
return { error: "SendTime không hợp lệ." };
}
options.sendTime = date;
}
return { options };
};
const handleConfirmSend = async () => {
if (!pendingAction) return;
const { options, error } = parseSendOptions();
if (error) {
setConfirmError(error);
toast.error(error);
return;
}
try {
if (pendingAction.type === "selected") {
await onExecuteSelected?.(pendingAction.targets, options);
} else {
await onExecuteCustom?.(
pendingAction.targets,
pendingAction.commandData,
options
);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
}
} catch (e) {
console.error("Confirm send error:", e);
} finally {
resetConfirmState();
setTimeout(() => window.location.reload(), 500);
}
};
const openRoomDialog = () => {
if (rooms.length > 0 && onExecuteSelected) {
setDialogType("room");
@ -295,6 +138,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
}
};
const handleExecuteSelected = () => {
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 mục để thực thi!");
return;
}
onExecuteSelected?.([]);
};
const handleExecuteAll = () => {
if (!onExecuteSelected) return;
try {
@ -302,7 +160,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
typeof room === "string" ? room : room.name,
);
const allTargets = [...roomNames, ...devices];
openConfirmForSelected(allTargets);
onExecuteSelected(allTargets);
} catch (e) {
console.error("Execute error:", e);
}
@ -320,7 +178,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
isRetained: customRetained,
};
openConfirmForCustom(targets, shellCommandData);
try {
await onExecuteCustom?.(targets, shellCommandData);
setCustomCommand("");
setCustomQoS(0);
setCustomRetained(false);
} catch (e) {
console.error("Execute custom command error:", e);
}
};
const handleExecuteCustomAll = () => {
@ -478,7 +343,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh"
@ -487,11 +354,13 @@ export function CommandSubmitTemplate<T extends { id: number }>({
onConfirm={async (selectedItems) => {
if (!onExecuteSelected) return;
try {
openConfirmForSelected(selectedItems);
await onExecuteSelected(selectedItems);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -502,21 +371,27 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
onSelect={async (deviceIds) => {
if (!onExecuteSelected) {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
return;
}
try {
openConfirmForSelected(deviceIds);
await onExecuteSelected(deviceIds);
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -527,7 +402,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<SelectDialog
open={dialogOpen2}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
title="Chọn phòng"
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
@ -539,7 +416,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
@ -550,7 +429,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
<DeviceSearchDialog
open={dialogOpen2 && dialogType === "device-custom"}
onClose={() => {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}}
rooms={rooms}
fetchDevices={getDeviceFromRoom}
@ -560,67 +441,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
} catch (e) {
console.error("Execute error:", e);
} finally {
closeTargetDialog();
setDialogOpen2(false);
setDialogType(null);
setTimeout(() => window.location.reload(), 500);
}
}}
/>
)}
{/* Dialog xác nhận gửi lệnh */}
<Dialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) resetConfirmState();
}}
>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Xác nhận gửi lệnh</DialogTitle>
<DialogDescription>
Vui lòng xác nhận nhập thông tin bổ sung trước khi gửi.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
<Input
id="ttl-minutes"
type="number"
min={0}
placeholder="VD: 60"
value={ttlMinutesInput}
onChange={(e) => setTtlMinutesInput(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="send-time">SendTime</Label>
<Input
id="send-time"
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
value={sendTimeInput}
onChange={(e) => setSendTimeInput(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Đ trống nếu muốn gửi ngay.
</p>
</div>
{confirmError && (
<p className="text-sm text-red-600">{confirmError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={resetConfirmState}>
Hủy
</Button>
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog for add/edit */}
{formContent && (
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>

View File

@ -1,277 +0,0 @@
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { DeviceOverviewCard } from "@/components/cards/device-overview-card";
import { RoomManagementCard } from "@/components/cards/room-management-card";
import { SoftwareDistributionCard } from "@/components/cards/software-distribution-card";
import { RefreshCw } from "lucide-react";
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type {
DashboardGeneralInfo,
DeviceOverviewResponse,
RoomManagementResponse,
RoomUsageResponse,
SoftwareDistributionResponse,
DeviceStatusByRoom,
} from "@/types/dashboard";
interface DashboardTemplateProps {
generalInfo?: DashboardGeneralInfo | null;
deviceOverview?: DeviceOverviewResponse | null;
roomManagement?: RoomManagementResponse | null;
roomUsage?: RoomUsageResponse | null;
softwareDistribution?: SoftwareDistributionResponse | null;
devicesByRoom?: DeviceStatusByRoom[] | null;
isLoading?: boolean;
isFetching?: boolean;
onRefresh?: () => void | Promise<void>;
}
export function DashboardTemplate({
generalInfo,
deviceOverview,
roomManagement,
roomUsage,
softwareDistribution,
devicesByRoom,
isLoading = false,
isFetching = false,
onRefresh,
}: DashboardTemplateProps) {
const [showAllRooms, setShowAllRooms] = useState(false);
const [usageRange, setUsageRange] = useState<"weekly" | "monthly">("weekly");
const totalDevices = generalInfo?.totalDevices ?? deviceOverview?.totalDevices ?? 0;
const onlineDevices = generalInfo?.onlineDevices ?? deviceOverview?.onlineDevices ?? 0;
const offlineDevices = generalInfo?.offlineDevices ?? deviceOverview?.offlineDevices ?? 0;
const totalRooms = generalInfo?.totalRooms ?? roomManagement?.totalRooms ?? 0;
const roomsNeedAttentionCount = roomManagement?.roomsNeedAttention?.length ?? 0;
const onlineRate = totalDevices > 0 ? Math.round((onlineDevices / totalDevices) * 100) : 0;
const stats = [
{
label: "Tổng thiết bị",
value: totalDevices,
note: `Online ${onlineRate}%`,
},
{
label: "Đang online",
value: onlineDevices,
note: "Thiết bị kết nối",
},
{
label: "Đang offline",
value: offlineDevices,
note: "Cần kiểm tra",
},
{
label: "Tổng phòng",
value: totalRooms,
note: `${roomsNeedAttentionCount} phòng cần chú ý`,
},
];
const getRoomPercent = (room: DeviceStatusByRoom) => {
if (typeof room.onlinePercentage === "number") return room.onlinePercentage;
if (!room.totalDevices) return 0;
return (room.onlineDevices / room.totalDevices) * 100;
};
const sortedRooms = useMemo(() => {
return (devicesByRoom ?? [])
.slice()
.sort((a, b) => getRoomPercent(a) - getRoomPercent(b));
}, [devicesByRoom]);
const roomSnapshot = showAllRooms ? sortedRooms : sortedRooms.slice(0, 6);
const usageSeries = useMemo(() => {
const usageRooms = roomUsage?.rooms ?? [];
const usageKey = usageRange === "weekly" ? "weekly" : "monthly";
const aggregated = new Map<string, number>();
for (const room of usageRooms) {
for (const point of room[usageKey]) {
aggregated.set(
point.date,
(aggregated.get(point.date) ?? 0) + point.onlineDevices
);
}
}
return Array.from(aggregated.entries())
.map(([date, value]) => ({
date,
value,
label: new Date(date).toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
}),
}))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}, [roomUsage, usageRange]);
return (
<div className="dashboard-scope relative w-full px-6 py-8">
<div className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-br from-slate-50 via-white to-emerald-50" />
<div className="pointer-events-none absolute -top-20 right-10 -z-10 h-72 w-72 rounded-full bg-emerald-200/40 blur-3xl" />
<div className="pointer-events-none absolute top-32 -left-24 -z-10 h-72 w-72 rounded-full bg-sky-200/40 blur-3xl" />
<div className="pointer-events-none absolute bottom-0 right-1/3 -z-10 h-64 w-64 rounded-full bg-amber-100/40 blur-3xl" />
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="space-y-2">
<h1 className="text-3xl font-semibold tracking-tight">Bảng điều khiển</h1>
<p className="text-sm text-muted-foreground">
Tổng hợp sức khỏe thiết bị, phòng phân phối phần mềm trong thời gian thực.
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
Cần chú ý: {roomsNeedAttentionCount} phòng
</Badge>
<Button onClick={() => onRefresh?.()} disabled={isFetching} className="gap-2">
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{isFetching ? "Đang làm mới" : "Làm mới"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
{stats.map((stat) => (
<div
key={stat.label}
className="rounded-2xl border border-muted/60 bg-white/80 p-4 shadow-sm backdrop-blur"
>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{stat.label}</div>
<div className="mt-2 flex items-end justify-between">
<div className="text-3xl font-semibold">{stat.value}</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">{stat.note}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 animate-in fade-in slide-in-from-bottom-6 duration-700">
<div className="flex flex-col gap-6 xl:col-span-2">
<DeviceOverviewCard data={deviceOverview ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tần suất sử dụng</CardTitle>
<CardDescription>Tổng thiết bị online theo ngày</CardDescription>
<CardAction>
<div className="flex items-center gap-2">
<Button
type="button"
size="sm"
variant={usageRange === "weekly" ? "default" : "outline"}
onClick={() => setUsageRange("weekly")}
>
7 ngày
</Button>
<Button
type="button"
size="sm"
variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")}
>
30 ngày
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{usageSeries.length > 0 ? (
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={usageSeries} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.4} />
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
formatter={(value) => [value ?? 0, "Online"]}
labelFormatter={(label) => `Ngày ${label}`}
/>
<Area
type="monotone"
dataKey="value"
stroke="#0ea5e9"
fill="url(#usageGradient)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu sử dụng</div>
)}
</CardContent>
</Card>
<RoomManagementCard data={roomManagement ?? null} isLoading={!!isLoading} />
</div>
<div className="flex flex-col gap-6">
<SoftwareDistributionCard data={softwareDistribution ?? null} isLoading={!!isLoading} />
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader>
<CardTitle>Tình trạng theo phòng</CardTitle>
<CardDescription>
Online / Tổng thiết bị từng phòng ({sortedRooms.length} phòng)
</CardDescription>
{sortedRooms.length > 6 && (
<CardAction>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowAllRooms((value) => !value)}
>
{showAllRooms ? "Thu gọn" : "Xem tất cả"}
</Button>
</CardAction>
)}
</CardHeader>
<CardContent className={showAllRooms ? "space-y-4 max-h-[480px] overflow-auto pr-2" : "space-y-4"}>
{roomSnapshot.length > 0 ? (
roomSnapshot.map((room) => {
const percentRaw = getRoomPercent(room);
const percent = Math.max(0, Math.min(100, Math.round(percentRaw)));
return (
<div key={room.roomName} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{room.roomName}</div>
<div className="text-xs text-muted-foreground">
{room.onlineDevices}/{room.totalDevices}
</div>
</div>
<div className="flex items-center gap-3">
<div className="h-2 flex-1 rounded-full bg-muted/40">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${percent}%` }}
/>
</div>
<div className="w-10 text-right text-xs font-medium">{percent}%</div>
</div>
</div>
);
})
) : (
<div className="text-sm text-muted-foreground">Chưa dữ liệu phòng</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { AppWindow, Building, CircleX, ClipboardList, Folder, Home, Monitor, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { AppWindow, Building, CircleX, Folder, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react";
import { PermissionEnum } from "./permission";
enum AppSidebarSectionCode {
@ -12,7 +12,6 @@ enum AppSidebarSectionCode {
LIST_ROLES = 8,
LIST_PERMISSIONS = 9,
LIST_USERS = 10,
REMOTE_LIVE_CONTROL = 11,
}
export const appSidebarSection = {
@ -27,7 +26,7 @@ export const appSidebarSection = {
code: AppSidebarSectionCode.DASHBOARD,
icon: Home,
permissions: [PermissionEnum.ALLOW_ALL],
}
},
],
},
{
@ -40,13 +39,6 @@ export const appSidebarSection = {
icon: Building,
permissions: [PermissionEnum.VIEW_ROOM],
},
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
}
],
},
{
@ -101,17 +93,6 @@ export const appSidebarSection = {
permissions: [PermissionEnum.VIEW_USER],
}
]
},
{
title: "Audits",
items: [
{
title: "Lịch sử hoạt động",
url: "/audits",
icon: ClipboardList,
permissions: [PermissionEnum.VIEW_AUDIT_LOGS],
}
]
}
],
};

View File

@ -1,29 +0,0 @@
export interface Audits {
id: number;
username: string;
dateTime: string; // ISO string
// request identity
apiCall?: string; // Controller.ActionName
url?: string;
requestPayload?: string; // request body (redacted)
// DB fields — null if request didn't touch DB
action?: string;
tableName?: string;
entityId?: string;
oldValues?: string;
newValues?: string;
// result
isSuccess?: boolean;
errorMessage?: string;
}
export interface PageResult<T> {
items: T[];
totalCount: number;
pageNumber: number;
pageSize: number;
totalPages: number;
}

View File

@ -6,7 +6,6 @@ export interface CommandRegistry {
commandContent: string;
qoS: 0 | 1 | 2;
isRetained: boolean;
ttlMinutes: number;
createdAt?: string;
updatedAt?: string;
}

View File

@ -1,105 +0,0 @@
export interface RecentOfflineDevice {
deviceId: string;
room?: string | null;
lastSeen?: string | null;
}
export interface DeviceOverviewResponse {
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
devicesWithOutdatedVersion: number;
recentOfflineDevices: RecentOfflineDevice[];
}
export interface DeviceStatusByRoom {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
onlinePercentage?: number;
}
export interface DashboardGeneralInfo {
totalDevices: number;
totalRooms: number;
onlineDevices: number;
offlineDevices: number;
}
export interface RoomUsagePoint {
date: string;
onlineDevices: number;
}
export interface RoomUsageItem {
roomName: string;
weekly: RoomUsagePoint[];
monthly: RoomUsagePoint[];
weeklyTotalOnlineDevices: number;
monthlyTotalOnlineDevices: number;
}
export interface RoomUsageResponse {
weekFrom: string;
weekTo: string;
monthFrom: string;
monthTo: string;
rooms: RoomUsageItem[];
}
export interface RoomHealthStatus {
roomName: string;
totalDevices: number;
onlineDevices: number;
offlineDevices: number;
healthPercentage?: number;
healthStatus?: string;
}
export interface RoomManagementResponse {
totalRooms: number;
rooms: RoomHealthStatus[];
roomsNeedAttention: RoomHealthStatus[];
}
export interface AgentVersionStats {
latestVersion: string;
devicesWithLatestVersion: number;
devicesWithOldVersion: number;
updateCoverage?: number;
}
export interface TopFailedSoftware {
fileName: string;
failCount: number;
}
export interface RecentInstallActivity {
deviceId: string;
fileName: string;
status: string;
timestamp: string;
message?: string | null;
}
export interface SoftwareDistributionResponse {
totalInstallations: number;
successfulInstallations: number;
failedInstallations: number;
pendingInstallations: number;
successRate?: number;
agentVersionStats: AgentVersionStats;
topFailedSoftware: TopFailedSoftware[];
recentActivities: RecentInstallActivity[];
}
export interface DashboardSummaryResponse {
generalInfo: DashboardGeneralInfo;
roomUsage: RoomUsageResponse;
deviceOverview: DeviceOverviewResponse;
roomManagement: RoomManagementResponse;
softwareDistribution: SoftwareDistributionResponse;
generatedAt: string;
}

View File

@ -3,7 +3,6 @@ export type Version = {
version: string;
fileName: string;
folderPath: string;
syncFolder?: string;
updatedAt?: string;
requestUpdateAt?: string;
isRequired: boolean;

View File

@ -44,7 +44,6 @@ export enum PermissionEnum {
EDIT_COMMAND = 53,
DEL_COMMAND = 54,
SEND_COMMAND = 55,
SEND_SENSITIVE_COMMAND = 56,
//DEVICE_OPERATION
DEVICE_OPERATION = 70,
@ -60,12 +59,10 @@ export enum PermissionEnum {
VIEW_ACCOUNT_ROOM = 115,
EDIT_ACCOUNT_ROOM = 116,
//WARNING_OPERATION
WARNING_OPERATION = 140,
VIEW_WARNING = 141,
//USER_OPERATION
USER_OPERATION = 150,
VIEW_USER_ROLE = 151,
@ -83,7 +80,7 @@ export enum PermissionEnum {
DEL_ROLE = 164,
// AGENT
AGENT_OPERATION = 170,
APP_OPERATION = 170,
VIEW_AGENT = 171,
UPDATE_AGENT = 173,
SEND_UPDATE_COMMAND = 174,
@ -97,18 +94,9 @@ export enum PermissionEnum {
ADD_APP_TO_SELECTED = 185,
DEL_APP_FROM_SELECTED = 186,
// AUDIT
AUDIT_OPERATION = 190,
VIEW_AUDIT_LOGS = 191,
//REMOTE CONTROL
REMOTE_CONTROL_OPERATION = 200,
VIEW_REMOTE_CONTROL = 201,
CONTROL_REMOTE = 202,
//Undefined
UNDEFINED = 9999,
//Allow All
ALLOW_ALL = 0
ALLOW_ALL = 0,
}

View File

@ -1,41 +1,11 @@
export type UserProfile = {
userId?: number;
userName: string;
name: string;
role: string;
roleId: number;
accessRooms: string[];
accessRooms: number[];
createdAt?: string | null;
createdBy?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserInfoRequest = {
name: string;
userName: string;
accessRooms?: number[];
};
export type UpdateUserRoleRequest = {
roleId: number;
};
export type UpdateUserInfoResponse = {
userId: number;
userName: string;
name: string;
roleId: number;
accessRooms: number[];
updatedAt?: string | null;
updatedBy?: string | null;
};
export type UpdateUserRoleResponse = {
userId: number;
userName: string;
roleId: number;
roleName?: string | null;
updatedAt?: string | null;
updatedBy?: string | null;
};

View File

@ -4,7 +4,6 @@
"outDir": "./dist",
"composite": true,
"noEmit": false,
"ignoreDeprecations": "6.0",
"types": []
},
"include": ["src/**/*.ts", "src/**/*.tsx"],

View File

@ -4,7 +4,6 @@ import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
import tailwindcss from "@tailwindcss/vite"
import path from 'path'
import basicSsl from '@vitejs/plugin-basic-ssl'
// https://vitejs.dev/config/
export default defineConfig({
@ -16,8 +15,7 @@ export default defineConfig({
}),
react(),
tailwindcss(),
basicSsl()
tailwindcss()
// ...,
],
resolve: {
@ -25,28 +23,4 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/mesh-api': {
target: 'https://my-mesh-test.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/mesh-api/, ''),
secure: false, // Bỏ qua lỗi SSL của MeshCentral
configure: (proxy, options) => {
proxy.on('proxyRes', (proxyRes) => {
const setCookie = proxyRes.headers['set-cookie'];
if (setCookie) {
// Sửa toàn bộ Cookie trả về: Đổi Lax -> None, thêm Secure
proxyRes.headers['set-cookie'] = setCookie.map(cookie => {
// Nếu gặp cookie trống (e30=), ta có thể bỏ qua hoặc giữ nhưng phải ép None
return cookie
.replace(/SameSite=Lax/gi, 'SameSite=None')
.replace(/SameSite=Strict/gi, 'SameSite=None') + '; Secure';
});
}
});
},
},
},
},
})