Compare commits
No commits in common. "main" and "feature_update_button" have entirely different histories.
main
...
feature_up
|
|
@ -20,6 +20,6 @@ COPY --from=development /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
EXPOSE 80 443
|
EXPOSE 80
|
||||||
|
|
||||||
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]
|
ENTRYPOINT [ "nginx", "-g", "daemon off;" ]
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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.
|
|
||||||
74
Users-API.md
74
Users-API.md
|
|
@ -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.
|
|
||||||
|
|
||||||
----------------------------------------
|
|
||||||
|
|
@ -3,13 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="icon" href="/public/computer-956.svg" />
|
||||||
<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" />
|
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
|
|
|
||||||
199
nginx/nginx.conf
199
nginx/nginx.conf
|
|
@ -1,209 +1,60 @@
|
||||||
# upstream backend {
|
upstream backend {
|
||||||
# server 100.66.170.15:8080;
|
server 100.66.170.15:8080;
|
||||||
# server 127.0.0.1:8080;
|
server 127.0.0.1:5218;
|
||||||
# server 172.18.10.8:8080;
|
server 172.18.10.8:8080;
|
||||||
# }
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
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;
|
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
# Default file to serve for directory requests
|
||||||
index index.html index.htm;
|
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 / {
|
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;
|
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)$ {
|
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public";
|
add_header Cache-Control "public";
|
||||||
access_log off;
|
access_log off; # Optional: Don't log accesses for static files
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://$backend_server;
|
proxy_pass http://backend/;
|
||||||
|
|
||||||
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;
|
proxy_request_buffering off;
|
||||||
|
|
||||||
|
# Tăng timeout khi upload
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_connect_timeout 300s;
|
proxy_connect_timeout 300s;
|
||||||
proxy_send_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) {
|
if ($request_method = OPTIONS) {
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/Sse/events {
|
location /api/Sse/events {
|
||||||
proxy_pass http://$backend_server/api/Sse/events;
|
proxy_pass http://backend/api/Sse/events;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# cần thiết cho SSE
|
||||||
proxy_set_header Connection '';
|
proxy_set_header Connection '';
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_cache off;
|
proxy_cache off;
|
||||||
proxy_read_timeout 1h;
|
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, but strip proxy prefix first.
|
|
||||||
# Without this, upstream sees /api/meshcentral/proxy/* and can redirect-loop.
|
|
||||||
rewrite ^/api/meshcentral/proxy/(.*)$ /$1 break;
|
|
||||||
proxy_pass http://$meshserver;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
2072
package-lock.json
generated
2072
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -22,7 +22,6 @@
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.7",
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"@tanstack/react-form": "^1.23.0",
|
"@tanstack/react-form": "^1.23.0",
|
||||||
|
|
@ -31,17 +30,13 @@
|
||||||
"@tanstack/react-router-devtools": "^1.121.2",
|
"@tanstack/react-router-devtools": "^1.121.2",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.4.3",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"shadcn": "^2.9.3",
|
"shadcn": "^2.9.3",
|
||||||
"sidebar": "^1.0.0",
|
"sidebar": "^1.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
@ -56,7 +51,6 @@
|
||||||
"@types/node": "^24.1.0",
|
"@types/node": "^24.1.0",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"jsdom": "^26.0.0",
|
"jsdom": "^26.0.0",
|
||||||
"tw-animate-css": "^1.3.6",
|
"tw-animate-css": "^1.3.6",
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB |
38
src/App.css
Normal file
38
src/App.css
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
height: 40vmin;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #282c34;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(10px + 2vmin);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-link {
|
||||||
|
color: #61dafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes App-logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
0
src/App.tsx
Normal file
0
src/App.tsx
Normal file
|
|
@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useForm, formOptions } from "@tanstack/react-form";
|
import { useForm, formOptions } from "@tanstack/react-form";
|
||||||
import axios from "@/config/axios";
|
import axios from "axios";
|
||||||
|
|
||||||
interface AddBlacklistDialogProps {
|
interface AddBlacklistDialogProps {
|
||||||
onAdded?: () => void; // callback để refresh danh sách sau khi thêm
|
onAdded?: () => void; // callback để refresh danh sách sau khi thêm
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator
|
|
||||||
} from "./ui/breadcrumb";
|
|
||||||
import { Link, useMatches } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export default function AppBreadCrumb() {
|
|
||||||
const matches = useMatches();
|
|
||||||
|
|
||||||
const crumbs = matches
|
|
||||||
.filter((m) => Boolean(m.context.breadcrumbs))
|
|
||||||
.map((m) => m.context.breadcrumbs)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const displayCrumbs = crumbs[0] as { path: string; title: string }[];
|
|
||||||
if (displayCrumbs == null || displayCrumbs.length == 0) return;
|
|
||||||
return (
|
|
||||||
<Breadcrumb className="flex-1">
|
|
||||||
<BreadcrumbList>
|
|
||||||
{displayCrumbs.slice(0, -1).map((b, index) => (
|
|
||||||
<>
|
|
||||||
<BreadcrumbItem key={index} className="md:block">
|
|
||||||
<Link to={b.path}>{b.title}</Link>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
<BreadcrumbItem className="md:block">
|
|
||||||
<BreadcrumbPage>{displayCrumbs[displayCrumbs.length - 1].title}</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
99
src/components/app-sidebar.tsx
Normal file
99
src/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type React from "react";
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { Building2, Cpu } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type MenuItem = {
|
||||||
|
title: string;
|
||||||
|
to: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
onPointerEnter?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppSidebarProps = {
|
||||||
|
items: MenuItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppSidebar({ items }: AppSidebarProps) {
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
collapsible="icon"
|
||||||
|
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||||
|
>
|
||||||
|
<SidebarHeader className="border-b border-border/40 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
|
||||||
|
<Building2 className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
|
||||||
|
TTMT Computer Management
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
|
||||||
|
<Cpu className="size-3" />
|
||||||
|
v1.0.0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent className="p-4">
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
|
||||||
|
Navigation
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu className="space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
tooltip={item.title}
|
||||||
|
onPointerEnter={item.onPointerEnter}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
|
||||||
|
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
|
||||||
|
"transition-all duration-200 ease-in-out",
|
||||||
|
"group relative overflow-hidden",
|
||||||
|
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
||||||
|
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={item.to}
|
||||||
|
to={"."}
|
||||||
|
className="flex items-center gap-3 w-full"
|
||||||
|
>
|
||||||
|
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
|
||||||
|
<span className="font-medium text-sm truncate">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
|
||||||
|
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
|
||||||
|
© 2025 NAVIS Centre
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { LogOut, Settings, User, Key } from "lucide-react";
|
|
||||||
|
|
||||||
interface AvatarDropdownProps {
|
|
||||||
username: string;
|
|
||||||
role: {
|
|
||||||
roleName: string;
|
|
||||||
priority: number;
|
|
||||||
};
|
|
||||||
onLogOut: () => void;
|
|
||||||
onSettings?: () => void;
|
|
||||||
onProfile?: () => void;
|
|
||||||
onChangePassword?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AvatarDropdown({
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
onLogOut,
|
|
||||||
onSettings,
|
|
||||||
onProfile,
|
|
||||||
onChangePassword,
|
|
||||||
}: AvatarDropdownProps) {
|
|
||||||
// Get initials from username
|
|
||||||
const getInitials = (name: string): string => {
|
|
||||||
if (!name) return "U";
|
|
||||||
const parts = name.split(" ");
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
|
|
||||||
}
|
|
||||||
return name.substring(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="flex items-center gap-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
|
|
||||||
<Avatar className="h-9 w-9 cursor-pointer">
|
|
||||||
<AvatarImage src="" alt={username} />
|
|
||||||
<AvatarFallback className="bg-primary text-primary-foreground text-sm font-medium">
|
|
||||||
{getInitials(username)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<p className="text-sm font-medium leading-none">{username}</p>
|
|
||||||
<p className="text-xs leading-none text-muted-foreground">
|
|
||||||
{role.roleName || "Người dùng"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{onProfile && (
|
|
||||||
<DropdownMenuItem onClick={onProfile} className="cursor-pointer">
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>Thông tin cá nhân</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{onChangePassword && (
|
|
||||||
<DropdownMenuItem onClick={onChangePassword} className="cursor-pointer">
|
|
||||||
<Key className="mr-2 h-4 w-4" />
|
|
||||||
<span>Đổi mật khẩu</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{onSettings && (
|
|
||||||
<DropdownMenuItem onClick={onSettings} className="cursor-pointer">
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
<span>Cài đặt</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{(onProfile || onChangePassword || onSettings) && <DropdownMenuSeparator />}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={onLogOut}
|
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Đăng xuất</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,354 +0,0 @@
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Building2,
|
|
||||||
Monitor,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
import type { DeviceHealthCheck } from "@/types/device";
|
|
||||||
|
|
||||||
interface DeviceSearchDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
rooms: Room[];
|
|
||||||
onSelect: (deviceIds: string[]) => void | Promise<void>;
|
|
||||||
fetchDevices: (roomName: string) => Promise<DeviceHealthCheck[]>; // API fetch
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeviceSearchDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
rooms,
|
|
||||||
onSelect,
|
|
||||||
fetchDevices,
|
|
||||||
}: DeviceSearchDialogProps) {
|
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
|
||||||
const [expandedRoom, setExpandedRoom] = useState<string | null>(null);
|
|
||||||
const [roomDevices, setRoomDevices] = useState<
|
|
||||||
Record<string, DeviceHealthCheck[]>
|
|
||||||
>({});
|
|
||||||
const [loadingRoom, setLoadingRoom] = useState<string | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
|
||||||
return [...rooms].sort((a, b) => {
|
|
||||||
const nameA = typeof a.name === "string" ? a.name : "";
|
|
||||||
const nameB = typeof b.name === "string" ? b.name : "";
|
|
||||||
return nameA.localeCompare(nameB);
|
|
||||||
});
|
|
||||||
}, [rooms]);
|
|
||||||
|
|
||||||
const filteredRooms = useMemo(() => {
|
|
||||||
if (!searchQuery) return sortedRooms;
|
|
||||||
return sortedRooms.filter((room) =>
|
|
||||||
room.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
}, [sortedRooms, searchQuery]);
|
|
||||||
|
|
||||||
const handleRoomClick = async (roomName: string) => {
|
|
||||||
// Nếu đang mở thì đóng lại
|
|
||||||
if (expandedRoom === roomName) {
|
|
||||||
setExpandedRoom(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nếu chưa fetch devices của room này thì gọi API
|
|
||||||
if (!roomDevices[roomName]) {
|
|
||||||
setLoadingRoom(roomName);
|
|
||||||
try {
|
|
||||||
const devices = await fetchDevices(roomName);
|
|
||||||
setRoomDevices((prev) => ({ ...prev, [roomName]: devices }));
|
|
||||||
setExpandedRoom(roomName);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch devices:", error);
|
|
||||||
// Có thể thêm toast notification ở đây
|
|
||||||
} finally {
|
|
||||||
setLoadingRoom(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Đã có data rồi thì chỉ toggle
|
|
||||||
setExpandedRoom(roomName);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDevice = (deviceId: string) => {
|
|
||||||
setSelected((prev) =>
|
|
||||||
prev.includes(deviceId)
|
|
||||||
? prev.filter((id) => id !== deviceId)
|
|
||||||
: [...prev, deviceId]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAllInRoom = (roomName: string) => {
|
|
||||||
const devices = roomDevices[roomName] || [];
|
|
||||||
const deviceIds = devices.map((d) => d.id);
|
|
||||||
const allSelected = deviceIds.every((id) => selected.includes(id));
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
setSelected((prev) => prev.filter((id) => !deviceIds.includes(id)));
|
|
||||||
} else {
|
|
||||||
setSelected((prev) => [...new Set([...prev, ...deviceIds])]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
try {
|
|
||||||
await onSelect(selected);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error on select:", e);
|
|
||||||
} finally {
|
|
||||||
setSelected([]);
|
|
||||||
setExpandedRoom(null);
|
|
||||||
setRoomDevices({});
|
|
||||||
setSearchQuery("");
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setSelected([]);
|
|
||||||
setExpandedRoom(null);
|
|
||||||
setRoomDevices({});
|
|
||||||
setSearchQuery("");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
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]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Monitor className="w-6 h-6 text-primary" />
|
|
||||||
Chọn thiết bị
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Search bar */}
|
|
||||||
<Input
|
|
||||||
placeholder="Tìm kiếm phòng..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Room list */}
|
|
||||||
<ScrollArea className="max-h-[500px] rounded-lg border p-2">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredRooms.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-8">
|
|
||||||
Không tìm thấy phòng
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredRooms.map((room) => {
|
|
||||||
const isExpanded = expandedRoom === room.name;
|
|
||||||
const isLoading = loadingRoom === room.name;
|
|
||||||
const devices = roomDevices[room.name] || [];
|
|
||||||
const 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) =>
|
|
||||||
selected.includes(d.id)
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={room.name}
|
|
||||||
className="border rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Room header - clickable */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1.5 hover:bg-muted/50 cursor-pointer"
|
|
||||||
onClick={() => handleRoomClick(room.name)}
|
|
||||||
>
|
|
||||||
{/* Expand icon or loading */}
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className="w-4 h-4 text-muted-foreground flex-shrink-0 animate-spin" />
|
|
||||||
) : isExpanded ? (
|
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Select all checkbox - chỉ hiện khi đã load devices */}
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={() => {
|
|
||||||
toggleAllInRoom(room.name);
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className={
|
|
||||||
someSelected && !allSelected ? "opacity-50" : ""
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Building2 className="w-4 h-4 text-primary flex-shrink-0" />
|
|
||||||
|
|
||||||
<span className="font-semibold flex-1 text-sm">
|
|
||||||
{room.name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground flex-shrink-0">
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<span className="text-primary font-medium">
|
|
||||||
{selectedCount}/
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{room.numberOfDevices}</span>
|
|
||||||
{room.numberOfOfflineDevices > 0 && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-700">
|
|
||||||
{room.numberOfOfflineDevices}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device table - collapsible */}
|
|
||||||
{isExpanded && sortedDevices.length > 0 && (
|
|
||||||
<div className="border-t bg-muted/20 overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead className="bg-muted/50 border-b sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="w-8 px-1 py-1"></th>
|
|
||||||
<th className="text-left px-1 py-1 font-medium min-w-20 text-xs">
|
|
||||||
Thiết bị
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-1 py-1 font-medium min-w-24 text-xs">
|
|
||||||
IP
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-1 py-1 font-medium min-w-28 text-xs">
|
|
||||||
MAC
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-1 py-1 font-medium min-w-12 text-xs">
|
|
||||||
Ver
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-1 py-1 font-medium min-w-16 text-xs">
|
|
||||||
Trạng thái
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedDevices.map((device) => (
|
|
||||||
<tr
|
|
||||||
key={device.id}
|
|
||||||
className="border-b last:border-b-0 hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<td className="px-1 py-1">
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.includes(device.id)}
|
|
||||||
onCheckedChange={() =>
|
|
||||||
toggleDevice(device.id)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1">
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
<Monitor className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<span className="font-mono text-xs truncate">
|
|
||||||
{device.id}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1 font-mono text-xs truncate">
|
|
||||||
{device.networkInfos[0]?.ipAddress || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1 font-mono text-xs truncate">
|
|
||||||
{device.networkInfos[0]?.macAddress || "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1 text-xs whitespace-nowrap">
|
|
||||||
{device.version ? `v${device.version}` : "-"}
|
|
||||||
</td>
|
|
||||||
<td className="px-1 py-1 text-xs">
|
|
||||||
{device.isOffline ? (
|
|
||||||
<span className="text-xs px-1 py-0.5 rounded-full bg-red-100 text-red-700 font-medium whitespace-nowrap inline-block">
|
|
||||||
Offline
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs px-1 py-0.5 rounded-full bg-green-100 text-green-700 font-medium whitespace-nowrap inline-block">
|
|
||||||
Online
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Selected count */}
|
|
||||||
{selected.length > 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground bg-muted/50 px-2 py-1.5 rounded">
|
|
||||||
Đã chọn:{" "}
|
|
||||||
<span className="font-semibold text-foreground">
|
|
||||||
{selected.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={handleClose} size="sm">
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={selected.length === 0}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Xác nhận ({selected.length})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { useGetSensitiveCommands, useExecuteSensitiveCommand } from "@/hooks/queries/useCommandQueries";
|
|
||||||
import { CommandType } from "@/types/command-registry";
|
|
||||||
import {
|
|
||||||
Power,
|
|
||||||
PowerOff,
|
|
||||||
XCircle,
|
|
||||||
ShieldBan,
|
|
||||||
ChevronDown,
|
|
||||||
Loader2,
|
|
||||||
AlertTriangle
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface CommandActionButtonsProps {
|
|
||||||
roomName: string;
|
|
||||||
selectedDevices?: string[]; // Các thiết bị đã chọn
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMAND_TYPE_CONFIG = {
|
|
||||||
[CommandType.RESTART]: {
|
|
||||||
label: "Khởi động lại",
|
|
||||||
icon: Power,
|
|
||||||
color: "text-blue-600",
|
|
||||||
bgColor: "bg-blue-50 hover:bg-blue-100",
|
|
||||||
},
|
|
||||||
[CommandType.SHUTDOWN]: {
|
|
||||||
label: "Tắt máy",
|
|
||||||
icon: PowerOff,
|
|
||||||
color: "text-red-600",
|
|
||||||
bgColor: "bg-red-50 hover:bg-red-100",
|
|
||||||
},
|
|
||||||
[CommandType.TASKKILL]: {
|
|
||||||
label: "Kết thúc tác vụ",
|
|
||||||
icon: XCircle,
|
|
||||||
color: "text-orange-600",
|
|
||||||
bgColor: "bg-orange-50 hover:bg-orange-100",
|
|
||||||
},
|
|
||||||
[CommandType.BLOCK]: {
|
|
||||||
label: "Chặn",
|
|
||||||
icon: ShieldBan,
|
|
||||||
color: "text-purple-600",
|
|
||||||
bgColor: "bg-purple-50 hover:bg-purple-100",
|
|
||||||
},
|
|
||||||
[CommandType.RESET]: {
|
|
||||||
label : "Reset",
|
|
||||||
icon: Loader2,
|
|
||||||
color: "text-green-600",
|
|
||||||
bgColor: "bg-green-50 hover:bg-green-100",
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CommandActionButtons({ roomName, selectedDevices = [] }: CommandActionButtonsProps) {
|
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
|
||||||
open: boolean;
|
|
||||||
command: any;
|
|
||||||
commandType: CommandType;
|
|
||||||
isSensitive?: boolean;
|
|
||||||
}>({
|
|
||||||
open: false,
|
|
||||||
command: null,
|
|
||||||
commandType: CommandType.RESTART,
|
|
||||||
});
|
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
|
||||||
const [sensitivePassword, setSensitivePassword] = useState("");
|
|
||||||
|
|
||||||
// Query commands for each type
|
|
||||||
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
|
|
||||||
|
|
||||||
// Send command mutation (sensitive)
|
|
||||||
const executeSensitiveMutation = useExecuteSensitiveCommand();
|
|
||||||
|
|
||||||
// Build commands mapped by CommandType using the `command` field from sensitive data
|
|
||||||
const commandsByType: Record<number, any[]> = (Object.values(CommandType) as Array<number | string>)
|
|
||||||
.filter((v) => typeof v === "number")
|
|
||||||
.reduce((acc: Record<number, any[]>, type) => {
|
|
||||||
acc[type as number] = (sensitiveCommands || []).filter((c: any) => Number(c.command) === Number(type));
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<number, any[]>);
|
|
||||||
|
|
||||||
const handleCommandClick = (command: any, commandType: CommandType) => {
|
|
||||||
// When building from sensitiveCommands, all items here are sensitive
|
|
||||||
setConfirmDialog({
|
|
||||||
open: true,
|
|
||||||
command,
|
|
||||||
commandType,
|
|
||||||
isSensitive: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmExecute = async () => {
|
|
||||||
setIsExecuting(true);
|
|
||||||
try {
|
|
||||||
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
|
|
||||||
await executeSensitiveMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
command: confirmDialog.command.commandName,
|
|
||||||
password: sensitivePassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast.success(`Đã gửi lệnh: ${confirmDialog.command.commandName}`);
|
|
||||||
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
|
|
||||||
setSensitivePassword("");
|
|
||||||
|
|
||||||
// Reload page để tránh freeze
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Execute command error:", error);
|
|
||||||
toast.error("Lỗi khi gửi lệnh!");
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseDialog = () => {
|
|
||||||
if (!isExecuting) {
|
|
||||||
setConfirmDialog({ open: false, command: null, commandType: CommandType.RESTART, isSensitive: false });
|
|
||||||
setSensitivePassword("");
|
|
||||||
// Reload để tránh freeze
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCommandButton = (commandType: CommandType) => {
|
|
||||||
const config = COMMAND_TYPE_CONFIG[commandType];
|
|
||||||
const commands = commandsByType[commandType];
|
|
||||||
const Icon = config.icon;
|
|
||||||
|
|
||||||
if (!commands || commands.length === 0) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={commandType}
|
|
||||||
variant="outline"
|
|
||||||
disabled
|
|
||||||
size="sm"
|
|
||||||
className="gap-2 flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
|
||||||
{config.label}
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">(0)</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu key={commandType}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className={`gap-2 ${config.bgColor} border-${config.color.split('-')[1]}-200 flex-shrink-0`}
|
|
||||||
>
|
|
||||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
|
||||||
{config.label}
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">({commands.length})</span>
|
|
||||||
<ChevronDown className="h-3 w-3 ml-1" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="start"
|
|
||||||
side="bottom"
|
|
||||||
sideOffset={4}
|
|
||||||
alignOffset={0}
|
|
||||||
className="w-64"
|
|
||||||
avoidCollisions={true}
|
|
||||||
>
|
|
||||||
{commands.map((command: any) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={command.id}
|
|
||||||
onClick={() => handleCommandClick(command, commandType)}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{command.commandName}</span>
|
|
||||||
{command.description && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{command.description}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex 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))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Dialog */}
|
|
||||||
<Dialog open={confirmDialog.open} onOpenChange={handleCloseDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
|
||||||
Xác nhận thực thi lệnh
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-left space-y-3">
|
|
||||||
<p>
|
|
||||||
Bạn có chắc chắn muốn thực thi lệnh <strong>{confirmDialog.command?.commandName}</strong>?
|
|
||||||
</p>
|
|
||||||
{confirmDialog.command?.description && (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{confirmDialog.command.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{confirmDialog.isSensitive && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">Mật khẩu</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={sensitivePassword}
|
|
||||||
onChange={(e) => setSensitivePassword(e.target.value)}
|
|
||||||
className="w-full px-2 py-1 rounded border"
|
|
||||||
placeholder="Nhập mật khẩu để xác nhận"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="bg-muted p-3 rounded-md space-y-1">
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="font-medium">Phòng:</span> {roomName}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="font-medium">Loại lệnh:</span>{" "}
|
|
||||||
{COMMAND_TYPE_CONFIG[confirmDialog.commandType]?.label}
|
|
||||||
</p>
|
|
||||||
{selectedDevices.length > 0 && (
|
|
||||||
<p className="text-sm">
|
|
||||||
<span className="font-medium">Thiết bị đã chọn:</span>{" "}
|
|
||||||
{selectedDevices.length} thiết bị
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-orange-600 font-medium">
|
|
||||||
Lệnh sẽ được thực thi ngay lập tức và không thể hoàn tác.
|
|
||||||
</p>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCloseDialog}
|
|
||||||
disabled={isExecuting}
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleConfirmExecute}
|
|
||||||
disabled={isExecuting || (confirmDialog.isSensitive && !sensitivePassword)}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExecuting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Đang thực thi...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Xác nhận"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Trash2 } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface DeleteButtonProps {
|
|
||||||
onClick: () => void | Promise<void>;
|
|
||||||
loading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
label?: string;
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteButton({
|
|
||||||
onClick,
|
|
||||||
loading = false,
|
|
||||||
disabled = false,
|
|
||||||
label = "Xóa khỏi server",
|
|
||||||
title = "Xóa khỏi server",
|
|
||||||
description = "Bạn có chắc chắn muốn xóa các phần mềm này khỏi server không? Hành động này không thể hoàn tác.",
|
|
||||||
}: DeleteButtonProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [isConfirming, setIsConfirming] = useState(false);
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
setIsConfirming(true);
|
|
||||||
try {
|
|
||||||
await onClick();
|
|
||||||
} finally {
|
|
||||||
setIsConfirming(false);
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
disabled={loading || disabled}
|
|
||||||
className="gap-2 px-4"
|
|
||||||
>
|
|
||||||
{loading || isConfirming ? (
|
|
||||||
<span className="animate-spin">⏳</span>
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{loading || isConfirming ? "Đang xóa..." : label}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-lg">{title}</DialogTitle>
|
|
||||||
<DialogDescription className="text-base">{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isConfirming}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={isConfirming || loading}
|
|
||||||
className="flex-1 gap-2"
|
|
||||||
>
|
|
||||||
{isConfirming ? (
|
|
||||||
<>
|
|
||||||
<span className="animate-spin">⏳</span>
|
|
||||||
Đang xóa...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{label}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
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 } 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,
|
|
||||||
}: {
|
|
||||||
device: any | undefined;
|
|
||||||
position: number;
|
|
||||||
folderStatus?: ClientFolderStatus;
|
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}) {
|
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
|
||||||
const [showRemote, setShowRemote] = useState(false);
|
|
||||||
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return (
|
|
||||||
<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}
|
|
||||||
</div>
|
|
||||||
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
|
||||||
<span className="text-xs text-muted-foreground">Trống</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOffline = device.isOffline;
|
|
||||||
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;
|
|
||||||
const [checking, setChecking] = useState(false);
|
|
||||||
|
|
||||||
const { data: status, isLoading } = useGetClientFolderStatusForDevice(
|
|
||||||
deviceId,
|
|
||||||
room,
|
|
||||||
checking
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCheck = () => setChecking((s) => !s);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={handleCheck}
|
|
||||||
className="inline-flex items-center gap-2 px-3 py-1 rounded border bg-background text-sm"
|
|
||||||
>
|
|
||||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
|
||||||
Kiểm tra thư mục Setup
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{checking && isLoading && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-2">Đang kiểm tra...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{checking && !isLoading && status && (
|
|
||||||
<div className="text-xs mt-2">
|
|
||||||
<div className="font-medium">Các file trong thư mục Setup({status.currentFiles?.length ?? 0})</div>
|
|
||||||
<div className="mt-1 max-h-36 overflow-auto space-y-1">
|
|
||||||
{(status.currentFiles ?? []).length === 0 ? (
|
|
||||||
<div className="text-muted-foreground">Không có file hiện tại</div>
|
|
||||||
) : (
|
|
||||||
(status.currentFiles ?? []).map((f: any) => (
|
|
||||||
<div key={f.fileName} className="font-mono text-xs">
|
|
||||||
<div className="truncate">{f.fileName}</div>
|
|
||||||
{f.lastModified && (
|
|
||||||
<div className="text-muted-foreground text-[10px]">
|
|
||||||
{new Date(f.lastModified).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{checking && !isLoading && !status && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-2">Không có dữ liệu</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const DeviceInfo = () => (
|
|
||||||
<div className="space-y-3 min-w-[280px]">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Thời gian thiết bị</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium">{new Date(device.deviceTime).toLocaleDateString("vi-VN")}</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{new Date(device.deviceTime).toLocaleTimeString("vi-VN")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Phiên bản</div>
|
|
||||||
<Badge variant="secondary" className="font-mono text-xs">
|
|
||||||
v{device.version}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Phòng</div>
|
|
||||||
<div className="text-sm font-medium">{device.room}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{device.networkInfos?.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Thông tin mạng</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{device.networkInfos.map((info: any, idx: number) => (
|
|
||||||
<div key={idx} className="text-xs font-mono bg-muted/50 p-2 rounded">
|
|
||||||
<div>MAC: {info.macAddress ?? "-"}</div>
|
|
||||||
<div>IP: {info.ipAddress ?? "-"}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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 />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
|
|
||||||
<Badge
|
|
||||||
variant={isOffline ? "destructive" : "default"}
|
|
||||||
className={`flex items-center gap-1 w-fit ${
|
|
||||||
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" />}
|
|
||||||
{isOffline ? "Offline" : "Online"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
|
||||||
isOffline
|
|
||||||
? "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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{position}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Folder Status Icon */}
|
|
||||||
{device && !isOffline && (
|
|
||||||
<div className="absolute -top-2 -right-2">
|
|
||||||
<FolderStatusPopover
|
|
||||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
|
||||||
status={folderStatus}
|
|
||||||
isLoading={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
|
||||||
{firstNetworkInfo?.ipAddress && (
|
|
||||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
|
||||||
{firstNetworkInfo.ipAddress}
|
|
||||||
{agentVersion && (
|
|
||||||
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
|
||||||
v{agentVersion}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium",
|
|
||||||
isOffline ? "text-red-700" : "text-green-700"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isOffline ? "Off" : "On"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto" side="top" align="center">
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 và 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 có thiết bị offline gần đây</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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">Có thể có 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 lý phòng</CardTitle>
|
|
||||||
<CardDescription>Thông tin tổng quan và 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 có phòng cần chú ý</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 kê cài đặt và 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 có 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,70 +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: "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">Có</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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
68
src/components/command-form.tsx
Normal file
68
src/components/command-form.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface ShellCommandFormProps {
|
||||||
|
command: string;
|
||||||
|
onCommandChange: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShellCommandForm({
|
||||||
|
command,
|
||||||
|
onCommandChange,
|
||||||
|
disabled,
|
||||||
|
}: ShellCommandFormProps) {
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: { command },
|
||||||
|
onSubmit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
className="space-y-5"
|
||||||
|
>
|
||||||
|
<form.Field
|
||||||
|
name="command"
|
||||||
|
validators={{
|
||||||
|
onChange: ({ value }: { value: string }) => {
|
||||||
|
const schema = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Nhập command để thực thi")
|
||||||
|
.max(500, "Command quá dài");
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
return result.error.issues.map((i) => i.message);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
children={(field) => (
|
||||||
|
<div className="w-full px-0">
|
||||||
|
<Textarea
|
||||||
|
className="w-full h-[25vh]"
|
||||||
|
placeholder="Nhập lệnh..."
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.handleChange(e.target.value);
|
||||||
|
onCommandChange(e.target.value);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
{field.state.meta.errors?.length > 0 && (
|
||||||
|
<p className="text-sm text-red-500">
|
||||||
|
{String(field.state.meta.errors[0])}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/components/computer-card.tsx
Normal file
129
src/components/computer-card.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Monitor, Wifi, WifiOff } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ComputerCard({
|
||||||
|
device,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
device: any | undefined;
|
||||||
|
position: number;
|
||||||
|
}) {
|
||||||
|
if (!device) {
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs text-muted-foreground">Trống</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOffline = device.isOffline;
|
||||||
|
const firstNetworkInfo = device.networkInfos?.[0];
|
||||||
|
|
||||||
|
const DeviceInfo = () => (
|
||||||
|
<div className="space-y-3 min-w-[280px]">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Thời gian thiết bị</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium">{new Date(device.deviceTime).toLocaleDateString("vi-VN")}</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{new Date(device.deviceTime).toLocaleTimeString("vi-VN")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Phiên bản</div>
|
||||||
|
<Badge variant="secondary" className="font-mono text-xs">
|
||||||
|
v{device.version}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Phòng</div>
|
||||||
|
<div className="text-sm font-medium">{device.room}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device.networkInfos?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Thông tin mạng</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{device.networkInfos.map((info: any, idx: number) => (
|
||||||
|
<div key={idx} className="text-xs font-mono bg-muted/50 p-2 rounded">
|
||||||
|
<div>MAC: {info.macAddress ?? "-"}</div>
|
||||||
|
<div>IP: {info.ipAddress ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">Trạng thái</div>
|
||||||
|
<Badge
|
||||||
|
variant={isOffline ? "destructive" : "default"}
|
||||||
|
className={`flex items-center gap-1 w-fit ${
|
||||||
|
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" />}
|
||||||
|
{isOffline ? "Offline" : "Online"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col items-center justify-center w-24 h-24 rounded-lg border-2 transition-all hover:scale-105 cursor-pointer",
|
||||||
|
isOffline
|
||||||
|
? "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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{position}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Monitor className={cn("h-6 w-6 mb-1", isOffline ? "text-red-600" : "text-green-600")} />
|
||||||
|
{firstNetworkInfo?.ipAddress && (
|
||||||
|
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||||
|
{firstNetworkInfo.ipAddress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{isOffline ? (
|
||||||
|
<WifiOff className="h-3 w-3 text-red-600" />
|
||||||
|
) : (
|
||||||
|
<Wifi className="h-3 w-3 text-green-600" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium",
|
||||||
|
isOffline ? "text-red-700" : "text-green-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOffline ? "Off" : "On"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto" side="top" align="center">
|
||||||
|
<DeviceInfo />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,8 @@
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "./computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||||
import type { ClientFolderStatus } from "@/types/folder";
|
|
||||||
|
|
||||||
export function DeviceGrid({
|
export function DeviceGrid({ devices }: { devices: any[] }) {
|
||||||
devices,
|
|
||||||
folderStatuses,
|
|
||||||
isCheckingFolder,
|
|
||||||
}: {
|
|
||||||
devices: any[];
|
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}) {
|
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
const deviceMap = new Map<number, any>();
|
const deviceMap = new Map<number, any>();
|
||||||
|
|
||||||
|
|
@ -23,27 +14,18 @@ export function DeviceGrid({
|
||||||
const totalRows = 5;
|
const totalRows = 5;
|
||||||
|
|
||||||
const renderRow = (rowIndex: number) => {
|
const renderRow = (rowIndex: number) => {
|
||||||
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
|
// Trái: 1–20
|
||||||
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
|
const leftStart = rowIndex * 4 + 1;
|
||||||
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
|
// Phải: 21–40
|
||||||
|
const rightStart = 21 + rowIndex * 4;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||||
{/* Bên trái (21–40) */}
|
{/* Bên trái (1–20) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = leftStart + (3 - i);
|
const pos = leftStart + i;
|
||||||
const device = deviceMap.get(pos);
|
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
|
||||||
key={pos}
|
|
||||||
device={device}
|
|
||||||
position={pos}
|
|
||||||
folderStatus={folderStatus}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
@ -52,21 +34,11 @@ export function DeviceGrid({
|
||||||
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bên phải (1–20) */}
|
{/* Bên phải (21–40) */}
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const pos = rightStart + (3 - i);
|
const pos = rightStart + i;
|
||||||
const device = deviceMap.get(pos);
|
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComputerCard
|
<ComputerCard key={pos} device={deviceMap.get(pos)} position={pos} />
|
||||||
key={pos}
|
|
||||||
device={device}
|
|
||||||
position={pos}
|
|
||||||
folderStatus={folderStatus}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,18 +47,19 @@ export function DeviceGrid({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-0.5 py-8 space-y-6">
|
<div className="px-0.5 py-8 space-y-6">
|
||||||
<div className="space-y-4">
|
|
||||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
|
<div className="flex items-center justify-between px-4 pb-6 border-b-2 border-dashed">
|
||||||
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
|
||||||
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
|
||||||
<span className="font-semibold text-lg">Cửa Ra Vào</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
|
<div className="flex items-center gap-3 px-6 py-4 bg-primary/10 rounded-lg border-2 border-primary/20">
|
||||||
<Monitor className="h-6 w-6 text-primary" />
|
<Monitor className="h-6 w-6 text-primary" />
|
||||||
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
|
<span className="font-semibold text-lg">Bàn Giảng Viên</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
||||||
|
<DoorOpen className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<span className="font-semibold text-lg">Cửa Ra Vào</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -17,21 +17,16 @@ import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Wifi, WifiOff, Clock, MapPin, Monitor, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "../hooks/useMachineNumber";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
|
||||||
|
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
isCheckingFolder?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component hiển thị danh sách thiết bị ở dạng bảng
|
* Component hiển thị danh sách thiết bị ở dạng bảng
|
||||||
*/
|
*/
|
||||||
export function DeviceTable({
|
export function DeviceTable({ devices }: DeviceTableProps) {
|
||||||
devices,
|
|
||||||
isCheckingFolder,
|
|
||||||
}: DeviceTableProps) {
|
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
|
|
@ -142,25 +137,6 @@ export function DeviceTable({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: "Thư mục Setup",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const device = row.original;
|
|
||||||
const isOffline = device.isOffline;
|
|
||||||
const macAddress = device.networkInfos?.[0]?.macAddress || device.id;
|
|
||||||
|
|
||||||
if (isOffline) {
|
|
||||||
return <span className="text-muted-foreground text-sm">-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FolderStatusPopover
|
|
||||||
deviceId={macAddress}
|
|
||||||
isLoading={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|
@ -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ị cũ
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { type ReactNode, useState } from "react";
|
|
||||||
|
|
||||||
interface FormDialogProps {
|
|
||||||
triggerLabel: string;
|
|
||||||
title: string;
|
|
||||||
children: (closeDialog: () => void) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormDialog({ triggerLabel, title, children }: FormDialogProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const closeDialog = () => setIsOpen(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>{triggerLabel}</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{children(closeDialog)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
|
|
||||||
export interface SelectItem {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
items: SelectItem[];
|
|
||||||
selectedValues?: string[];
|
|
||||||
onConfirm: (values: string[]) => Promise<void> | void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SelectDialog({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
title,
|
|
||||||
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())
|
|
||||||
);
|
|
||||||
}, [items, search]);
|
|
||||||
|
|
||||||
const toggleItem = (value: string) => {
|
|
||||||
setSelected((prev) =>
|
|
||||||
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
await onConfirm(selected);
|
|
||||||
setSelected([]);
|
|
||||||
setSearch("");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
|
||||||
<DialogContent className="max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
{icon}
|
|
||||||
{title}
|
|
||||||
</DialogTitle>
|
|
||||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Tìm kiếm..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-h-64 overflow-y-auto space-y-2 mt-2 border rounded p-2">
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<div key={item.value} className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={selected.includes(item.value)}
|
|
||||||
onCheckedChange={() => toggleItem(item.value)}
|
|
||||||
/>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{filteredItems.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground text-center">Không có kết quả</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-4">
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirm} disabled={selected.length === 0}>
|
|
||||||
Xác nhận
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { CheckCircle2, AlertCircle, Loader2, AlertTriangle } from "lucide-react";
|
|
||||||
import type { ClientFolderStatus } from "@/types/folder";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
interface FolderStatusPopoverProps {
|
|
||||||
deviceId: string;
|
|
||||||
status?: ClientFolderStatus;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FolderStatusPopover({
|
|
||||||
deviceId,
|
|
||||||
status,
|
|
||||||
isLoading,
|
|
||||||
}: FolderStatusPopoverProps) {
|
|
||||||
const missing = status?.missingFiles ?? [];
|
|
||||||
const extra = status?.extraFiles ?? [];
|
|
||||||
const hasMissing = missing.length > 0;
|
|
||||||
const hasExtra = extra.length > 0;
|
|
||||||
const hasIssues = hasMissing || hasExtra;
|
|
||||||
|
|
||||||
// Xác định màu sắc và icon dựa trên trạng thái
|
|
||||||
let statusColor = "text-green-500";
|
|
||||||
let statusIcon = (
|
|
||||||
<CheckCircle2 className={`h-5 w-5 ${statusColor}`} />
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
statusColor = "text-blue-500";
|
|
||||||
statusIcon = <Loader2 className={`h-5 w-5 animate-spin ${statusColor}`} />;
|
|
||||||
} else if (hasMissing && hasExtra) {
|
|
||||||
// Vừa thiếu vừa thừa -> Đỏ + Alert
|
|
||||||
statusColor = "text-red-600";
|
|
||||||
statusIcon = <AlertTriangle className={`h-5 w-5 ${statusColor}`} />;
|
|
||||||
} else if (hasMissing) {
|
|
||||||
// Chỉ thiếu -> Đỏ
|
|
||||||
statusColor = "text-red-500";
|
|
||||||
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
|
|
||||||
} else if (hasExtra) {
|
|
||||||
// Chỉ thừa -> Cam
|
|
||||||
statusColor = "text-orange-500";
|
|
||||||
statusIcon = <AlertCircle className={`h-5 w-5 ${statusColor}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button className="p-2 hover:bg-muted rounded-md transition-colors">
|
|
||||||
{statusIcon}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96 p-4" side="right">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="text-sm font-semibold">Thư mục Setup: {deviceId}</div>
|
|
||||||
{hasIssues && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
{hasMissing && hasExtra
|
|
||||||
? "Không đồng bộ"
|
|
||||||
: hasMissing
|
|
||||||
? "Thiếu file"
|
|
||||||
: "Thừa file"}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Đang kiểm tra...
|
|
||||||
</div>
|
|
||||||
) : !status ? (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Chưa có dữ liệu
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* File thiếu */}
|
|
||||||
{hasMissing && (
|
|
||||||
<div className="border-l-4 border-red-500 pl-3">
|
|
||||||
<h4 className="text-sm font-semibold text-red-600 mb-2 flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
File thiếu ({missing.length})
|
|
||||||
</h4>
|
|
||||||
<ScrollArea className="h-32 rounded-md border bg-red-50/30 p-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{missing.map((file, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="text-xs bg-white rounded p-2 border border-red-200"
|
|
||||||
>
|
|
||||||
<div className="font-mono font-semibold text-red-700">
|
|
||||||
{file.fileName}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
{file.folderPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File thừa */}
|
|
||||||
{hasExtra && (
|
|
||||||
<div className="border-l-4 border-orange-500 pl-3">
|
|
||||||
<h4 className="text-sm font-semibold text-orange-600 mb-2 flex items-center gap-2">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
File thừa ({extra.length})
|
|
||||||
</h4>
|
|
||||||
<ScrollArea className="h-32 rounded-md border bg-orange-50/30 p-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{extra.map((file, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="text-xs bg-white rounded p-2 border border-orange-200"
|
|
||||||
>
|
|
||||||
<div className="font-mono font-semibold text-orange-700">
|
|
||||||
{file.fileName}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
|
||||||
{file.folderPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Trạng thái OK */}
|
|
||||||
{!hasIssues && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50/30 rounded p-3 border border-green-200">
|
|
||||||
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
|
|
||||||
<span className="font-medium">Thư mục đạt yêu cầu</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { FormBuilder, FormField } from "@/components/forms/dynamic-submit-form";
|
|
||||||
import { type BlacklistFormData } from "@/types/black-list";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface BlacklistFormProps {
|
|
||||||
onSubmit: (data: BlacklistFormData) => Promise<void>;
|
|
||||||
closeDialog: () => void;
|
|
||||||
initialData?: Partial<BlacklistFormData>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BlacklistForm({
|
|
||||||
onSubmit,
|
|
||||||
closeDialog,
|
|
||||||
initialData,
|
|
||||||
}: BlacklistFormProps) {
|
|
||||||
return (
|
|
||||||
<FormBuilder<BlacklistFormData>
|
|
||||||
defaultValues={{
|
|
||||||
appName: initialData?.appName || "",
|
|
||||||
processName: initialData?.processName || "",
|
|
||||||
}}
|
|
||||||
onSubmit={async (values: BlacklistFormData) => {
|
|
||||||
if (!values.appName.trim()) {
|
|
||||||
toast.error("Vui lòng nhập tên ứng dụng");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!values.processName.trim()) {
|
|
||||||
toast.error("Vui lòng nhập tên tiến trình");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await onSubmit(values);
|
|
||||||
toast.success("Thêm phần mềm bị chặn thành công!");
|
|
||||||
closeDialog();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error:", error);
|
|
||||||
toast.error("Có lỗi xảy ra!");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
submitLabel="Thêm"
|
|
||||||
cancelLabel="Hủy"
|
|
||||||
onCancel={closeDialog}
|
|
||||||
showCancel={true}
|
|
||||||
>
|
|
||||||
{(form: any) => (
|
|
||||||
<>
|
|
||||||
<FormField<BlacklistFormData, "appName">
|
|
||||||
form={form}
|
|
||||||
name="appName"
|
|
||||||
label="Tên ứng dụng"
|
|
||||||
placeholder="VD: Google Chrome"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField<BlacklistFormData, "processName">
|
|
||||||
form={form}
|
|
||||||
name="processName"
|
|
||||||
label="Tên tiến trình"
|
|
||||||
placeholder="VD: chrome.exe"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormBuilder>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useForm } from "@tanstack/react-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Info } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export interface ShellCommandData {
|
|
||||||
command: string;
|
|
||||||
qos: 0 | 1 | 2;
|
|
||||||
isRetained: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShellCommandFormProps {
|
|
||||||
command: string;
|
|
||||||
onCommandChange: (value: string) => void;
|
|
||||||
qos?: 0 | 1 | 2;
|
|
||||||
onQoSChange?: (value: 0 | 1 | 2) => void;
|
|
||||||
isRetained?: boolean;
|
|
||||||
onIsRetainedChange?: (value: boolean) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QoSDescriptions = {
|
|
||||||
0: {
|
|
||||||
name: "At Most Once (Fire and Forget)",
|
|
||||||
description:
|
|
||||||
"Gửi lệnh một lần mà không đảm bảo. Nhanh nhất, tiêu tốn ít tài nguyên.",
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
name: "At Least Once",
|
|
||||||
description:
|
|
||||||
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Cân bằng giữa tốc độ và độ tin cậy.",
|
|
||||||
},
|
|
||||||
2: {
|
|
||||||
name: "Exactly Once",
|
|
||||||
description:
|
|
||||||
"Đảm bảo lệnh được nhận chính xác một lần. Chậm nhất nhưng đáng tin cậy nhất.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ShellCommandForm({
|
|
||||||
command,
|
|
||||||
onCommandChange,
|
|
||||||
qos = 0,
|
|
||||||
onQoSChange,
|
|
||||||
isRetained = false,
|
|
||||||
onIsRetainedChange,
|
|
||||||
disabled,
|
|
||||||
}: ShellCommandFormProps) {
|
|
||||||
const [selectedQoS, setSelectedQoS] = useState<0 | 1 | 2>(qos);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues: { command },
|
|
||||||
onSubmit: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleQoSChange = (value: string) => {
|
|
||||||
const newQoS = Number(value) as 0 | 1 | 2;
|
|
||||||
setSelectedQoS(newQoS);
|
|
||||||
onQoSChange?.(newQoS);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRetainedChange = (checked: boolean) => {
|
|
||||||
onIsRetainedChange?.(checked);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
className="space-y-5"
|
|
||||||
>
|
|
||||||
{/* Command Input */}
|
|
||||||
<form.Field
|
|
||||||
name="command"
|
|
||||||
validators={{
|
|
||||||
onChange: ({ value }: { value: string }) => {
|
|
||||||
const schema = z
|
|
||||||
.string()
|
|
||||||
.min(1, "Nhập command để thực thi")
|
|
||||||
.max(500, "Command quá dài");
|
|
||||||
const result = schema.safeParse(value);
|
|
||||||
if (!result.success) {
|
|
||||||
return result.error.issues.map((i) => i.message);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
children={(field) => (
|
|
||||||
<div className="w-full space-y-2">
|
|
||||||
<Label>Nội Dung Lệnh *</Label>
|
|
||||||
<Textarea
|
|
||||||
className="w-full h-[20vh] font-mono"
|
|
||||||
placeholder="VD: shutdown /s /t 60 /c 'Máy sẽ tắt trong 60 giây'"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.handleChange(e.target.value);
|
|
||||||
onCommandChange(e.target.value);
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* QoS Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>QoS (Quality of Service) *</Label>
|
|
||||||
<select
|
|
||||||
value={selectedQoS}
|
|
||||||
onChange={(e) => handleQoSChange(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full h-10 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
>
|
|
||||||
<option value="0">QoS 0 - At Most Once (Tốc độ cao)</option>
|
|
||||||
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
|
|
||||||
<option value="2">QoS 2 - Exactly Once (Độ tin cậy cao)</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* QoS Description */}
|
|
||||||
<Alert className="border-l-4 border-l-blue-500 bg-blue-50 mt-2">
|
|
||||||
<Info className="h-4 w-4 text-blue-600" />
|
|
||||||
<AlertDescription className="text-sm text-blue-800 mt-1">
|
|
||||||
<div className="font-semibold">
|
|
||||||
{QoSDescriptions[selectedQoS].name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1">{QoSDescriptions[selectedQoS].description}</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Retained Checkbox */}
|
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
|
||||||
<Checkbox
|
|
||||||
id="retained"
|
|
||||||
checked={isRetained}
|
|
||||||
onCheckedChange={handleRetainedChange}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label htmlFor="retained" className="text-base cursor-pointer">
|
|
||||||
Lưu giữ lệnh (Retained)
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Broker MQTT sẽ lưu lệnh này và gửi cho client mới khi kết nối. Hữu ích
|
|
||||||
cho các lệnh cấu hình cần duy trì trạng thái.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,416 +0,0 @@
|
||||||
import { useForm } from "@tanstack/react-form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { Info } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { CommandType } from "@/types/command-registry";
|
|
||||||
|
|
||||||
interface CommandRegistryFormProps {
|
|
||||||
onSubmit: (data: CommandRegistryFormData) => Promise<void>;
|
|
||||||
closeDialog?: () => void;
|
|
||||||
initialData?: Partial<CommandRegistryFormData>;
|
|
||||||
title?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandRegistryFormData {
|
|
||||||
commandName: string;
|
|
||||||
commandType: CommandType;
|
|
||||||
description?: string;
|
|
||||||
commandContent: string;
|
|
||||||
qos: 0 | 1 | 2;
|
|
||||||
isRetained: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zod validation schema
|
|
||||||
const commandRegistrySchema = z.object({
|
|
||||||
commandName: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Tên lệnh không được để trống")
|
|
||||||
.min(3, "Tên lệnh phải có ít nhất 3 ký tự")
|
|
||||||
.max(100, "Tên lệnh tối đa 100 ký tự")
|
|
||||||
.trim(),
|
|
||||||
commandType: z.nativeEnum(CommandType, {
|
|
||||||
errorMap: () => ({ message: "Loại lệnh không hợp lệ" }),
|
|
||||||
}),
|
|
||||||
description: z.string().max(500, "Mô tả tối đa 500 ký tự").optional(),
|
|
||||||
commandContent: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Nội dung lệnh không được để trống")
|
|
||||||
.min(5, "Nội dung lệnh phải có ít nhất 5 ký tự")
|
|
||||||
.trim(),
|
|
||||||
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
|
||||||
isRetained: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QoSLevels = [
|
|
||||||
{
|
|
||||||
level: 0,
|
|
||||||
name: "At Most Once (Fire and Forget)",
|
|
||||||
description:
|
|
||||||
"Gửi lệnh một lần mà không đảm bảo. Nếu broker hoặc client bị ngắt kết nối, lệnh có thể bị mất. Tốc độ nhanh nhất, tiêu tốn ít tài nguyên.",
|
|
||||||
useCase: "Các lệnh không quan trọng, có thể mất mà không ảnh hưởng",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
level: 1,
|
|
||||||
name: "At Least Once",
|
|
||||||
description:
|
|
||||||
"Đảm bảo lệnh sẽ được nhận ít nhất một lần. Có thể gửi lại nếu chưa nhận được ACK. Lệnh có thể được nhận nhiều lần.",
|
|
||||||
useCase: "Hầu hết các lệnh bình thường cần đảm bảo gửi thành công",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
level: 2,
|
|
||||||
name: "Exactly Once",
|
|
||||||
description:
|
|
||||||
"Đảm bảo lệnh được nhận chính xác một lần. Sử dụng bắt tay 4 chiều để đảm bảo độ tin cậy cao nhất. Tốc độ chậm hơn, tiêu tốn nhiều tài nguyên.",
|
|
||||||
useCase: "Các lệnh quan trọng như xóa dữ liệu, thay đổi cấu hình",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function CommandRegistryForm({
|
|
||||||
onSubmit,
|
|
||||||
closeDialog,
|
|
||||||
initialData,
|
|
||||||
title = "Đăng ký Lệnh Mới",
|
|
||||||
}: CommandRegistryFormProps) {
|
|
||||||
const [selectedQoS, setSelectedQoS] = useState<number>(
|
|
||||||
initialData?.qos ?? 0
|
|
||||||
);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues: {
|
|
||||||
commandName: initialData?.commandName || "",
|
|
||||||
commandType: initialData?.commandType || CommandType.RESTART,
|
|
||||||
description: initialData?.description || "",
|
|
||||||
commandContent: initialData?.commandContent || "",
|
|
||||||
qos: (initialData?.qos || 0) as 0 | 1 | 2,
|
|
||||||
isRetained: initialData?.isRetained || false,
|
|
||||||
},
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
try {
|
|
||||||
// Validate using Zod
|
|
||||||
const validatedData = commandRegistrySchema.parse(value);
|
|
||||||
setIsSubmitting(true);
|
|
||||||
await onSubmit(validatedData as CommandRegistryFormData);
|
|
||||||
toast.success("Lưu lệnh thành công!");
|
|
||||||
if (closeDialog) {
|
|
||||||
closeDialog();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.errors?.length > 0) {
|
|
||||||
toast.error(error.errors[0].message);
|
|
||||||
} else {
|
|
||||||
console.error("Submit error:", error);
|
|
||||||
toast.error("Có lỗi xảy ra khi lưu lệnh!");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-[90vw] sm:max-w-[70vw] md:max-w-[50vw] mx-auto space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{title}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Tạo và cấu hình lệnh MQTT mới để điều khiển thiết bị
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form
|
|
||||||
className="space-y-6"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Tên lệnh */}
|
|
||||||
<form.Field name="commandName">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
Tên Lệnh <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="VD: RestartDevice, ShutdownPC, UpdateSoftware..."
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tên định danh duy nhất cho lệnh này
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Loại lệnh */}
|
|
||||||
<form.Field name="commandType">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
Loại Lệnh <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(Number(e.target.value))}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<option value={CommandType.RESTART}>RESTART - Khởi động lại</option>
|
|
||||||
<option value={CommandType.SHUTDOWN}>SHUTDOWN - Tắt máy</option>
|
|
||||||
<option value={CommandType.TASKKILL}>TASKKILL - Kết thúc tác vụ</option>
|
|
||||||
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
|
|
||||||
</select>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Phân loại lệnh để dễ dàng quản lý và tổ chức
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Mô tả */}
|
|
||||||
<form.Field name="description">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Mô Tả (Tùy chọn)</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Nhập mô tả chi tiết về lệnh này..."
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Mô tả chi tiết về chức năng và cách sử dụng lệnh
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Nội dung lệnh */}
|
|
||||||
<form.Field name="commandContent">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
Nội Dung Lệnh <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="VD: shutdown /s /t 30 /c 'Máy sẽ tắt trong 30 giây'"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
rows={5}
|
|
||||||
className="font-mono text-sm"
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Nội dung lệnh sẽ được gửi tới thiết bị (PowerShell, CMD, bash...)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* QoS Level */}
|
|
||||||
<form.Field name="qos">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>
|
|
||||||
QoS (Quality of Service) <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = Number(e.target.value) as 0 | 1 | 2;
|
|
||||||
field.handleChange(value);
|
|
||||||
setSelectedQoS(value);
|
|
||||||
}}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
<option value="0">QoS 0 - At Most Once (Tốc độ cao)</option>
|
|
||||||
<option value="1">QoS 1 - At Least Once (Cân bằng)</option>
|
|
||||||
<option value="2">QoS 2 - Exactly Once (Độ tin cậy cao)</option>
|
|
||||||
</select>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Chú thích QoS */}
|
|
||||||
{selectedQoS !== null && (
|
|
||||||
<Alert className="border-l-4 border-l-blue-500 bg-blue-50">
|
|
||||||
<Info className="h-4 w-4 text-blue-600" />
|
|
||||||
<AlertDescription className="text-sm space-y-3 mt-2">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-blue-900">
|
|
||||||
{QoSLevels[selectedQoS].name}
|
|
||||||
</div>
|
|
||||||
<div className="text-blue-800 mt-1">
|
|
||||||
{QoSLevels[selectedQoS].description}
|
|
||||||
</div>
|
|
||||||
<div className="text-blue-700 mt-2">
|
|
||||||
<span className="font-medium">Trường hợp sử dụng:</span>{" "}
|
|
||||||
{QoSLevels[selectedQoS].useCase}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Bảng so sánh QoS */}
|
|
||||||
<Card className="bg-muted/50">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
Bảng So Sánh Các Mức QoS
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-2 px-2 font-semibold">
|
|
||||||
Tiêu Chí
|
|
||||||
</th>
|
|
||||||
<th className="text-center py-2 px-2 font-semibold">
|
|
||||||
QoS 0
|
|
||||||
</th>
|
|
||||||
<th className="text-center py-2 px-2 font-semibold">
|
|
||||||
QoS 1
|
|
||||||
</th>
|
|
||||||
<th className="text-center py-2 px-2 font-semibold">
|
|
||||||
QoS 2
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr className="border-b bg-white">
|
|
||||||
<td className="py-2 px-2">Đảm bảo gửi</td>
|
|
||||||
<td className="text-center">Không</td>
|
|
||||||
<td className="text-center">Có</td>
|
|
||||||
<td className="text-center">Chính xác</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b bg-white">
|
|
||||||
<td className="py-2 px-2">Tốc độ</td>
|
|
||||||
<td className="text-center">Nhanh nhất</td>
|
|
||||||
<td className="text-center">Trung bình</td>
|
|
||||||
<td className="text-center">Chậm nhất</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b bg-white">
|
|
||||||
<td className="py-2 px-2">Tài nguyên</td>
|
|
||||||
<td className="text-center">Ít nhất</td>
|
|
||||||
<td className="text-center">Trung bình</td>
|
|
||||||
<td className="text-center">Nhiều nhất</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="border-b bg-white">
|
|
||||||
<td className="py-2 px-2">Độ tin cậy</td>
|
|
||||||
<td className="text-center">Thấp</td>
|
|
||||||
<td className="text-center">Cao</td>
|
|
||||||
<td className="text-center">Cao nhất</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="bg-white">
|
|
||||||
<td className="py-2 px-2">Số lần nhận tối đa</td>
|
|
||||||
<td className="text-center">1 (hoặc 0)</td>
|
|
||||||
<td className="text-center">≥ 1</td>
|
|
||||||
<td className="text-center">1</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* IsRetained Checkbox */}
|
|
||||||
<form.Field name="isRetained">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="flex items-center gap-3 rounded-lg border p-4">
|
|
||||||
<Checkbox
|
|
||||||
checked={field.state.value}
|
|
||||||
onCheckedChange={field.handleChange}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-base cursor-pointer">
|
|
||||||
Lưu giữ lệnh (Retained)
|
|
||||||
</Label>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Broker MQTT sẽ lưu lệnh này và gửi cho client mới khi
|
|
||||||
kết nối. Hữu ích cho các lệnh cấu hình cần duy trì trạng
|
|
||||||
thái.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
{isSubmitting ? "Đang lưu..." : "Lưu Lệnh"}
|
|
||||||
</Button>
|
|
||||||
{closeDialog && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={closeDialog}
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
import { useForm } from "@tanstack/react-form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { type ReactNode } from "react";
|
|
||||||
|
|
||||||
interface FormBuilderProps<T extends Record<string, any>> {
|
|
||||||
defaultValues: T;
|
|
||||||
onSubmit: (values: T) => Promise<void> | void;
|
|
||||||
submitLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
onCancel?: () => void;
|
|
||||||
showCancel?: boolean;
|
|
||||||
children: (form: any) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormBuilder<T extends Record<string, any>>({
|
|
||||||
defaultValues,
|
|
||||||
onSubmit,
|
|
||||||
submitLabel = "Submit",
|
|
||||||
cancelLabel = "Hủy",
|
|
||||||
onCancel,
|
|
||||||
showCancel = false,
|
|
||||||
children,
|
|
||||||
}: FormBuilderProps<T>) {
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues,
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
try {
|
|
||||||
await onSubmit(value as T);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Submit error:", error);
|
|
||||||
toast.error("Có lỗi xảy ra!");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children(form)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{showCancel && onCancel && (
|
|
||||||
<Button type="button" variant="outline" onClick={onCancel}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="submit">{submitLabel}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FormFieldProps<T, K extends keyof T> {
|
|
||||||
form: any;
|
|
||||||
name: K;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
required?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormField<T extends Record<string, any>, K extends keyof T>({
|
|
||||||
form,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
type = "text",
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
}: FormFieldProps<T, K>) {
|
|
||||||
return (
|
|
||||||
<form.Field name={name as string}>
|
|
||||||
{(field: any) => (
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type={type}
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormTextarea<T extends Record<string, any>, K extends keyof T>({
|
|
||||||
form,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
placeholder,
|
|
||||||
required,
|
|
||||||
}: Omit<FormFieldProps<T, K>, "type">) {
|
|
||||||
return (
|
|
||||||
<form.Field name={name as string}>
|
|
||||||
{(field: any) => (
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</Label>
|
|
||||||
<textarea
|
|
||||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FormSelect<T extends Record<string, any>, K extends keyof T>({
|
|
||||||
form,
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
required,
|
|
||||||
}: {
|
|
||||||
form: any;
|
|
||||||
name: K;
|
|
||||||
label: string;
|
|
||||||
options: { value: string; label: string }[];
|
|
||||||
required?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<form.Field name={name as string}>
|
|
||||||
{(field: any) => (
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
{label}
|
|
||||||
{required && <span className="text-red-500 ml-1">*</span>}
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import type { LoginResquest } from "@/types/auth";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
|
||||||
import { Route } from "@/routes/(auth)/login";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { LoaderCircle } from "lucide-react";
|
|
||||||
|
|
||||||
export function LoginForm({ className }: React.ComponentProps<"form">) {
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [formData, setFormData] = useState<LoginResquest>({
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
const auth = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const search = Route.useSearch() as { redirect?: string };
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: login,
|
|
||||||
async onSuccess(data) {
|
|
||||||
localStorage.setItem("accesscontrol.auth.user", data.username!);
|
|
||||||
localStorage.setItem("token", data.token!);
|
|
||||||
localStorage.setItem("name", data.name!);
|
|
||||||
localStorage.setItem("acs", (data.access ?? "").toString());
|
|
||||||
localStorage.setItem("role", data.role.roleName ?? "");
|
|
||||||
localStorage.setItem("priority", (data.role.priority ?? 0).toString());
|
|
||||||
|
|
||||||
auth.setAuthenticated(true);
|
|
||||||
auth.login(data.username!);
|
|
||||||
|
|
||||||
await router.invalidate();
|
|
||||||
await navigate({ to: search.redirect || "/dashboard" });
|
|
||||||
},
|
|
||||||
onError(error) {
|
|
||||||
setErrorMessage(error.message || "Login failed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const 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);
|
|
||||||
mutation.mutate(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Hệ thống quản lý phòng máy thực hành</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="grid gap-6">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<Label htmlFor="email">Tên đăng nhập</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, username: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Label htmlFor="password">Mật khẩu</Label>
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFormData({ ...formData, password: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="text-destructive text-sm font-medium">{errorMessage}</div>
|
|
||||||
)}
|
|
||||||
{mutation.isPending ? (
|
|
||||||
<Button className="w-full" disabled>
|
|
||||||
<LoaderCircle className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
Đang đăng nhập
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
Đăng nhập
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<div 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>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { useForm } from "@tanstack/react-form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import type { AxiosProgressEvent } from "axios";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface UploadVersionFormProps {
|
|
||||||
onSubmit: (fd: FormData, config?: { onUploadProgress: (e: AxiosProgressEvent) => void }) => Promise<void>;
|
|
||||||
closeDialog: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormProps) {
|
|
||||||
const [uploadPercent, setUploadPercent] = useState(0);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
const [isDone, setIsDone] = useState(false);
|
|
||||||
|
|
||||||
// Match server allowed extensions
|
|
||||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
|
||||||
const isFileValid = (file: File) => {
|
|
||||||
const fileName = file.name.toLowerCase();
|
|
||||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
|
||||||
};
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
defaultValues: { files: new DataTransfer().files, newVersion: "" },
|
|
||||||
onSubmit: async ({ value }) => {
|
|
||||||
if (!value.newVersion || value.files.length === 0) {
|
|
||||||
toast.error("Vui lòng điền đầy đủ thông tin");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file types
|
|
||||||
const invalidFiles = Array.from(value.files).filter((f) => !isFileValid(f));
|
|
||||||
if (invalidFiles.length > 0) {
|
|
||||||
toast.error(
|
|
||||||
`File không hợp lệ: ${invalidFiles.map((f) => f.name).join(", ")}. Chỉ chấp nhận ${ALLOWED_EXTENSIONS.join(", ")}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsUploading(true);
|
|
||||||
setUploadPercent(0);
|
|
||||||
setIsDone(false);
|
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
|
|
||||||
fd.append("Version", value.newVersion);
|
|
||||||
|
|
||||||
await onSubmit(fd, {
|
|
||||||
onUploadProgress: (e: AxiosProgressEvent) => {
|
|
||||||
if (e.total) {
|
|
||||||
const progress = Math.round((e.loaded * 100) / e.total);
|
|
||||||
setUploadPercent(progress);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsDone(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Upload error:", error);
|
|
||||||
toast.error("Upload thất bại!");
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form.Field name="newVersion">
|
|
||||||
{(field) => (
|
|
||||||
<div>
|
|
||||||
<Label>Phiên bản</Label>
|
|
||||||
<Input
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder="1.0.0"
|
|
||||||
disabled={isUploading || isDone}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<form.Field name="files">
|
|
||||||
{(field) => (
|
|
||||||
<div>
|
|
||||||
<Label>File</Label>
|
|
||||||
<Input
|
|
||||||
type="file"
|
|
||||||
accept=".exe,.apk,.conf,.json,.xml,.setting,.lnk,.url,.seb"
|
|
||||||
onChange={(e) => e.target.files && field.handleChange(e.target.files)}
|
|
||||||
disabled={isUploading || isDone}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Chỉ chấp nhận file: .exe, .apk, .conf, .json, .xml, .setting, .lnk, .url, .seb
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{(uploadPercent > 0 || isUploading || isDone) && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
|
|
||||||
<span>{uploadPercent}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={uploadPercent} className="w-full" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{!isDone ? (
|
|
||||||
<>
|
|
||||||
<Button type="button" variant="outline" onClick={closeDialog} disabled={isUploading}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isUploading}>
|
|
||||||
{isUploading ? "Đang tải..." : "Upload"}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button type="button" onClick={closeDialog}>
|
|
||||||
Hoàn tất
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,140 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Loader2, Trash2, ChevronDown, AlertTriangle } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface DeleteMenuProps {
|
|
||||||
onDeleteFromServer: () => void;
|
|
||||||
onDeleteFromRequired: () => void;
|
|
||||||
loading?: boolean;
|
|
||||||
label?: string;
|
|
||||||
serverLabel?: string;
|
|
||||||
requiredLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteMenu({
|
|
||||||
onDeleteFromServer,
|
|
||||||
onDeleteFromRequired,
|
|
||||||
loading,
|
|
||||||
label = "Xóa",
|
|
||||||
serverLabel = "Xóa khỏi server",
|
|
||||||
requiredLabel = "Xóa khỏi danh sách yêu cầu",
|
|
||||||
}: DeleteMenuProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
|
|
||||||
|
|
||||||
const handleDeleteFromServer = async () => {
|
|
||||||
try {
|
|
||||||
await onDeleteFromServer();
|
|
||||||
} finally {
|
|
||||||
setOpen(false);
|
|
||||||
setShowConfirmDelete(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromRequired = async () => {
|
|
||||||
try {
|
|
||||||
await onDeleteFromRequired();
|
|
||||||
} finally {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={loading}
|
|
||||||
className="group relative overflow-hidden font-medium px-6 py-2.5 rounded-lg transition-all duration-300 hover:shadow-lg hover:shadow-red-200/50 hover:-translate-y-0.5 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="text-sm font-semibold">
|
|
||||||
{loading ? "Đang xóa..." : label}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="h-4 w-4 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-700 group-hover:translate-x-full" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={handleDeleteFromRequired}
|
|
||||||
disabled={loading}
|
|
||||||
className="focus:bg-orange-50 focus:text-orange-900"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2 text-orange-600" />
|
|
||||||
<span>{requiredLabel}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setShowConfirmDelete(true)}
|
|
||||||
disabled={loading}
|
|
||||||
className="focus:bg-red-50 focus:text-red-900"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2 text-red-600" />
|
|
||||||
<span>{serverLabel}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* Confirmation Dialog for Delete from Server */}
|
|
||||||
{showConfirmDelete && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-sm mx-4 shadow-lg">
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
|
||||||
<h3 className="font-semibold text-lg">Cảnh báo: Xóa khỏi server</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Bạn đang chuẩn bị xóa các phần mềm này khỏi server. Hành động này <strong>không thể hoàn tác</strong> và sẽ xóa vĩnh viễn tất cả các tệp liên quan.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-red-600 mb-6 font-medium">
|
|
||||||
Vui lòng chắc chắn trước khi tiếp tục.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowConfirmDelete(false)}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteFromServer}
|
|
||||||
disabled={loading}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Đang xóa...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Xóa khỏi server
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { AxiosError } from "axios";
|
|
||||||
|
|
||||||
export function ErrorFetchingPage({ error }: { error: Error | AxiosError }) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 text-destructive">
|
|
||||||
<h2 className="text-xl font-semibold mb-2">Lỗi</h2>
|
|
||||||
<p>
|
|
||||||
{"isAxiosError" in error &&
|
|
||||||
error.response?.data &&
|
|
||||||
(error.response.data as { message?: string }).message
|
|
||||||
? (error.response.data as { message?: string }).message
|
|
||||||
: "Lỗi trong quá trình render page"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { AlertTriangle } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export default function ErrorRoute({ error }: { error: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
|
|
||||||
<div className="bg-destructive/10 rounded-full p-6 mb-6">
|
|
||||||
<AlertTriangle className="h-12 w-12 text-destructive" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Lỗi</h1>
|
|
||||||
<p className="text-muted-foreground mb-8 max-w-md">
|
|
||||||
Đã xảy ra lỗi: <strong>{error}</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
|
||||||
<Button asChild variant="outline">
|
|
||||||
<Link to="/dashboard">Về trang chủ</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { ArrowLeft, Search } from "lucide-react";
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
|
|
||||||
<div className="space-y-6 max-w-md mx-auto">
|
|
||||||
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
|
|
||||||
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter">404</h1>
|
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold">Không tìm thấy</h2>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Trang bạn yêu cầu truy cập không tồn tại hoặc đã bị xoá.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6">
|
|
||||||
<Button asChild size="lg" className="gap-2">
|
|
||||||
<Link to="/dashboard" className="flex items-center gap-1">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Trở về trang chủ
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Route } from "@/routes/_auth";
|
|
||||||
import { useRouter } from "@tanstack/react-router";
|
|
||||||
import { ArrowLeft, Search } from "lucide-react";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
export default function SessionTimeOutErrorPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
const auth = useAuth();
|
|
||||||
const handleLogout = () => {
|
|
||||||
auth.logout();
|
|
||||||
router.invalidate().finally(() => {
|
|
||||||
navigate({ to: "/login" });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[70vh] px-4 text-center">
|
|
||||||
<div className="space-y-6 max-w-md mx-auto">
|
|
||||||
<div className="relative mx-auto w-40 h-40 md:w-52 md:h-52">
|
|
||||||
<div className="absolute inset-0 bg-primary/10 rounded-full animate-pulse" />
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Search className="h-20 w-20 md:h-24 md:w-24 text-primary" strokeWidth={1.5} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-2xl md:text-3xl font-semibold">Phiên hết hạn</h2>
|
|
||||||
<p className="text-muted-foreground">Bạn cần đăng nhập lại</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-6">
|
|
||||||
<Button asChild size="lg" className="gap-2" onClick={handleLogout}>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
Trở về trang đăng nhập
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
|
||||||
import { RowsPerPage } from "./rows-per-page";
|
|
||||||
|
|
||||||
interface PaginationProps {
|
|
||||||
currentPage: number;
|
|
||||||
totalPages: number;
|
|
||||||
totalItems: number;
|
|
||||||
itemsPerPage: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onPageSizeChange?: (pageSize: number) => void;
|
|
||||||
pageSizeOptions?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CustomPagination({
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
totalItems,
|
|
||||||
itemsPerPage,
|
|
||||||
onPageChange,
|
|
||||||
onPageSizeChange,
|
|
||||||
pageSizeOptions
|
|
||||||
}: PaginationProps) {
|
|
||||||
const startItem = Math.max(1, (currentPage - 1) * itemsPerPage + 1);
|
|
||||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
|
||||||
{onPageSizeChange && (
|
|
||||||
<RowsPerPage
|
|
||||||
pageSize={itemsPerPage}
|
|
||||||
onPageSizeChange={onPageSizeChange}
|
|
||||||
options={pageSizeOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(1)}
|
|
||||||
disabled={currentPage === 1}>
|
|
||||||
<ChevronsLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<span className="mx-2 text-sm">
|
|
||||||
{startItem}-{endItem} của {totalItems}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onPageChange(totalPages)}
|
|
||||||
disabled={currentPage === totalPages}>
|
|
||||||
<ChevronsRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
interface RowsPerPageProps {
|
|
||||||
pageSize: number;
|
|
||||||
onPageSizeChange: (pageSize: number) => void;
|
|
||||||
options?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RowsPerPage({
|
|
||||||
pageSize,
|
|
||||||
onPageSizeChange,
|
|
||||||
options = [5, 10, 15, 20]
|
|
||||||
}: RowsPerPageProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-muted-foreground">Hiển thị</span>
|
|
||||||
<Select
|
|
||||||
value={pageSize?.toString()}
|
|
||||||
onValueChange={(value) => onPageSizeChange(Number(value))}>
|
|
||||||
<SelectTrigger className="h-8 w-[70px]">
|
|
||||||
<SelectValue placeholder={pageSize?.toString()} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{!options.includes(pageSize) && (
|
|
||||||
<SelectItem value={pageSize?.toString()} disabled>
|
|
||||||
{pageSize}
|
|
||||||
</SelectItem>
|
|
||||||
)}
|
|
||||||
{options.map((option) => (
|
|
||||||
<SelectItem key={option} value={option?.toString()}>
|
|
||||||
{option}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<span className="text-sm text-muted-foreground">mục</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
151
src/components/preset-command.tsx
Normal file
151
src/components/preset-command.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Play, PlayCircle } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface PresetCommand {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
command: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetCommandsProps {
|
||||||
|
onSelectCommand: (command: string) => void
|
||||||
|
onExecuteMultiple?: (commands: string[]) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Danh sách các command có sẵn
|
||||||
|
const PRESET_COMMANDS: PresetCommand[] = [
|
||||||
|
{
|
||||||
|
id: "check-disk",
|
||||||
|
label: "Kiểm tra dung lượng ổ đĩa",
|
||||||
|
command: "df -h",
|
||||||
|
description: "Hiển thị thông tin dung lượng các ổ đĩa",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "check-memory",
|
||||||
|
label: "Kiểm tra RAM",
|
||||||
|
command: "free -h",
|
||||||
|
description: "Hiển thị thông tin bộ nhớ RAM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "check-cpu",
|
||||||
|
label: "Kiểm tra CPU",
|
||||||
|
command: "top -bn1 | head -20",
|
||||||
|
description: "Hiển thị thông tin CPU và tiến trình",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "list-processes",
|
||||||
|
label: "Danh sách tiến trình",
|
||||||
|
command: "ps aux",
|
||||||
|
description: "Liệt kê tất cả tiến trình đang chạy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "network-info",
|
||||||
|
label: "Thông tin mạng",
|
||||||
|
command: "ifconfig",
|
||||||
|
description: "Hiển thị cấu hình mạng",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "system-info",
|
||||||
|
label: "Thông tin hệ thống",
|
||||||
|
command: "uname -a",
|
||||||
|
description: "Hiển thị thông tin hệ điều hành",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "uptime",
|
||||||
|
label: "Thời gian hoạt động",
|
||||||
|
command: "uptime",
|
||||||
|
description: "Hiển thị thời gian hệ thống đã chạy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reboot",
|
||||||
|
label: "Khởi động lại",
|
||||||
|
command: "reboot",
|
||||||
|
description: "Khởi động lại thiết bị",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function PresetCommands({ onSelectCommand, onExecuteMultiple, disabled }: PresetCommandsProps) {
|
||||||
|
const [selectedCommands, setSelectedCommands] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const handleToggleCommand = (commandId: string) => {
|
||||||
|
setSelectedCommands((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(commandId)) {
|
||||||
|
newSet.delete(commandId)
|
||||||
|
} else {
|
||||||
|
newSet.add(commandId)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExecuteSelected = () => {
|
||||||
|
const commands = PRESET_COMMANDS.filter((cmd) => selectedCommands.has(cmd.id)).map((cmd) => cmd.command)
|
||||||
|
if (commands.length > 0 && onExecuteMultiple) {
|
||||||
|
onExecuteMultiple(commands)
|
||||||
|
setSelectedCommands(new Set()) // Clear selection after execution
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectedCommands.size === PRESET_COMMANDS.length) {
|
||||||
|
setSelectedCommands(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedCommands(new Set(PRESET_COMMANDS.map((cmd) => cmd.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
|
||||||
|
<Checkbox checked={selectedCommands.size === PRESET_COMMANDS.length} className="mr-2" />
|
||||||
|
{selectedCommands.size === PRESET_COMMANDS.length ? "Bỏ chọn tất cả" : "Chọn tất cả"}
|
||||||
|
</Button>
|
||||||
|
{selectedCommands.size > 0 && (
|
||||||
|
<Button size="sm" onClick={handleExecuteSelected} disabled={disabled}>
|
||||||
|
<PlayCircle className="h-4 w-4 mr-2" />
|
||||||
|
Thực thi {selectedCommands.size} lệnh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[25vh] w-full rounded-md border p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{PRESET_COMMANDS.map((preset) => (
|
||||||
|
<div
|
||||||
|
key={preset.id}
|
||||||
|
className="flex items-start gap-3 rounded-lg border p-3 hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedCommands.has(preset.id)}
|
||||||
|
onCheckedChange={() => handleToggleCommand(preset.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="font-medium text-sm">{preset.label}</div>
|
||||||
|
{preset.description && <div className="text-xs text-muted-foreground">{preset.description}</div>}
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded block mt-1">{preset.command}</code>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onSelectCommand(preset.command)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -7,18 +7,12 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
|
import { Loader2, RefreshCw, ChevronDown } from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface RequestUpdateMenuProps {
|
interface RequestUpdateMenuProps {
|
||||||
onUpdateDevice: () => void;
|
onUpdateDevice: () => void;
|
||||||
onUpdateRoom: () => void;
|
onUpdateRoom: () => void;
|
||||||
onUpdateAll: () => void;
|
onUpdateAll: () => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
label?: string;
|
|
||||||
deviceLabel?: string;
|
|
||||||
roomLabel?: string;
|
|
||||||
allLabel?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequestUpdateMenu({
|
export function RequestUpdateMenu({
|
||||||
|
|
@ -26,39 +20,9 @@ export function RequestUpdateMenu({
|
||||||
onUpdateRoom,
|
onUpdateRoom,
|
||||||
onUpdateAll,
|
onUpdateAll,
|
||||||
loading,
|
loading,
|
||||||
label = "Cập nhật",
|
|
||||||
deviceLabel = "Thiết bị cụ thể",
|
|
||||||
roomLabel = "Theo phòng",
|
|
||||||
allLabel = "Tất cả thiết bị",
|
|
||||||
icon,
|
|
||||||
}: RequestUpdateMenuProps) {
|
}: RequestUpdateMenuProps) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleUpdateDevice = async () => {
|
|
||||||
try {
|
|
||||||
await onUpdateDevice();
|
|
||||||
} finally {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateRoom = async () => {
|
|
||||||
try {
|
|
||||||
await onUpdateRoom();
|
|
||||||
} finally {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
|
||||||
try {
|
|
||||||
await onUpdateAll();
|
|
||||||
} finally {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -68,13 +32,11 @@ export function RequestUpdateMenu({
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
|
<Loader2 className="h-4 w-4 animate-spin text-gray-600" />
|
||||||
) : icon ? (
|
|
||||||
<div className="h-4 w-4 text-gray-600">{icon}</div>
|
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
|
<RefreshCw className="h-4 w-4 text-gray-600 transition-transform duration-300 group-hover:rotate-180" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{loading ? "Đang gửi..." : label}
|
{loading ? "Đang gửi..." : "Cập nhật"}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
<ChevronDown className="h-4 w-4 text-gray-600 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,19 +45,19 @@ export function RequestUpdateMenu({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuItem onClick={handleUpdateDevice} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateDevice} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{deviceLabel}</span>
|
<span>Cập nhật thiết bị cụ thể</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleUpdateRoom} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateRoom} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{roomLabel}</span>
|
<span>Cập nhật theo phòng</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleUpdateAll} disabled={loading}>
|
<DropdownMenuItem onClick={onUpdateAll} disabled={loading}>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
<span>{allLabel}</span>
|
<span>Cập nhật tất cả thiết bị</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
135
src/components/select-dialog.tsx
Normal file
135
src/components/select-dialog.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect, useState, useMemo } from "react"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Check, Search } from "lucide-react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
||||||
|
interface SelectDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
items: string[] // danh sách chung: có thể là devices hoặc rooms
|
||||||
|
title?: string // tiêu đề động
|
||||||
|
description?: string // mô tả ngắn
|
||||||
|
icon?: React.ReactNode // icon thay đổi tùy loại
|
||||||
|
onConfirm: (selected: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
items,
|
||||||
|
title = "Chọn mục",
|
||||||
|
description = "Bạn có thể chọn nhiều mục để thao tác",
|
||||||
|
icon,
|
||||||
|
onConfirm,
|
||||||
|
}: SelectDialogProps) {
|
||||||
|
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||||
|
const [search, setSearch] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedItems([])
|
||||||
|
setSearch("")
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const toggleItem = (item: string) => {
|
||||||
|
setSelectedItems((prev) =>
|
||||||
|
prev.includes(item)
|
||||||
|
? prev.filter((i) => i !== item)
|
||||||
|
: [...prev, item]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lọc danh sách theo từ khóa
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
return items.filter((item) =>
|
||||||
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
}, [items, search])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader className="text-center pb-4">
|
||||||
|
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
|
||||||
|
{icon ?? <Search className="w-6 h-6 text-primary" />}
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 🔍 Thanh tìm kiếm */}
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Tìm kiếm..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danh sách các item */}
|
||||||
|
<div className="py-3 space-y-3 max-h-64 overflow-y-auto">
|
||||||
|
{filteredItems.length > 0 ? (
|
||||||
|
filteredItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg border border-border hover:border-primary/60 hover:bg-accent/50 transition-all duration-200 cursor-pointer"
|
||||||
|
onClick={() => toggleItem(item)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedItems.includes(item)}
|
||||||
|
onCheckedChange={() => toggleItem(item)}
|
||||||
|
/>
|
||||||
|
<Label className="font-medium cursor-pointer hover:text-primary">
|
||||||
|
{item}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedItems.includes(item) && (
|
||||||
|
<div className="w-5 h-5 bg-primary rounded-full flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm text-muted-foreground py-4">
|
||||||
|
Không tìm thấy kết quả
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={onClose} className="flex-1 sm:flex-none">
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedItems.length > 0) {
|
||||||
|
onConfirm(selectedItems)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={selectedItems.length === 0}
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Xác nhận ({selectedItems.length})
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import type React from "react";
|
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
import { Building2, Cpu } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { appSidebarSection } from "@/types/app-sidebar";
|
|
||||||
import { PermissionEnum } from "@/types/permission";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
type SidebarItem = {
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
code?: number;
|
|
||||||
icon: React.ElementType;
|
|
||||||
permissions?: PermissionEnum[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type SidebarSection = {
|
|
||||||
title: string;
|
|
||||||
items: SidebarItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
const { hasPermission, acs, isSystemAdmin } = useAuth();
|
|
||||||
|
|
||||||
// Check if user is admin (has ALLOW_ALL permission OR is System Admin with priority 0)
|
|
||||||
const isAdmin = acs.includes(PermissionEnum.ALLOW_ALL) || isSystemAdmin();
|
|
||||||
|
|
||||||
// Check if user has any of the required permissions
|
|
||||||
const checkPermissions = (permissions?: PermissionEnum[]) => {
|
|
||||||
// No permissions defined = show to everyone
|
|
||||||
if (!permissions || permissions.length === 0) return true;
|
|
||||||
// Item marked as ALLOW_ALL = show to everyone
|
|
||||||
if (permissions.includes(PermissionEnum.ALLOW_ALL)) return true;
|
|
||||||
// Admin users OR System Admin (priority=0) see everything
|
|
||||||
if (isAdmin) return true;
|
|
||||||
// Check if user has any of the required permissions
|
|
||||||
return permissions.some((permission) => hasPermission(permission));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Filter sidebar sections and items based on permissions
|
|
||||||
const filteredNavMain = useMemo(() => {
|
|
||||||
return appSidebarSection.navMain
|
|
||||||
.map((section) => ({
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => checkPermissions(item.permissions)),
|
|
||||||
}))
|
|
||||||
.filter((section) => section.items.length > 0) as SidebarSection[];
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [acs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TooltipProvider delayDuration={300}>
|
|
||||||
<Sidebar
|
|
||||||
collapsible="icon"
|
|
||||||
className="border-r border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
|
||||||
>
|
|
||||||
<SidebarHeader className="border-b border-border/40 p-6">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex aspect-square size-10 items-center justify-center rounded-xl bg-gradient-to-br from-gray-900 to-black text-white shadow-lg ring-1 ring-gray-800/30">
|
|
||||||
<Building2 className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span className="font-bold text-base tracking-tight bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text">
|
|
||||||
TTMT Computer Management
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground font-medium flex items-center gap-1">
|
|
||||||
<Cpu className="size-3" />
|
|
||||||
v1.0.0
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent className="p-4">
|
|
||||||
{filteredNavMain.map((section) => (
|
|
||||||
<SidebarGroup key={section.title}>
|
|
||||||
<SidebarGroupLabel className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider mb-2">
|
|
||||||
{section.title}
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu className="space-y-1">
|
|
||||||
{section.items.map((item) => (
|
|
||||||
<SidebarMenuItem key={item.title}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
tooltip={item.title}
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-start gap-3 px-4 py-3 rounded-xl",
|
|
||||||
"hover:bg-accent/60 hover:text-accent-foreground hover:shadow-sm",
|
|
||||||
"transition-all duration-200 ease-in-out",
|
|
||||||
"group relative overflow-hidden",
|
|
||||||
"data-[active=true]:bg-primary data-[active=true]:text-primary-foreground",
|
|
||||||
"data-[active=true]:shadow-md data-[active=true]:ring-1 data-[active=true]:ring-primary/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={item.url}
|
|
||||||
to={item.url}
|
|
||||||
className="flex items-center gap-3 w-full"
|
|
||||||
>
|
|
||||||
<item.icon className="size-5 shrink-0 transition-all duration-200 group-hover:scale-110 group-hover:text-primary" />
|
|
||||||
<span className="font-medium text-sm truncate">
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="font-medium">
|
|
||||||
{item.title}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
))}
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter className="border-t border-border/40 p-4 space-y-3">
|
|
||||||
<div className="px-2 text-xs text-muted-foreground/60 font-medium">
|
|
||||||
© 2025 NAVIS Centre
|
|
||||||
</div>
|
|
||||||
</SidebarFooter>
|
|
||||||
</Sidebar>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
import {
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
getPaginationRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ColumnDef,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { CustomPagination } from "@/components/pagination/pagination";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface VersionTableProps<TData> {
|
|
||||||
data: TData[];
|
|
||||||
columns: ColumnDef<TData, any>[];
|
|
||||||
isLoading: boolean;
|
|
||||||
onTableInit?: (table: any) => void;
|
|
||||||
onRowClick?: (row: TData) => void;
|
|
||||||
scrollable?: boolean;
|
|
||||||
maxHeight?: string;
|
|
||||||
// Pagination options
|
|
||||||
enablePagination?: boolean;
|
|
||||||
defaultPageSize?: number;
|
|
||||||
pageSizeOptions?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VersionTable<TData>({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
isLoading,
|
|
||||||
onTableInit,
|
|
||||||
onRowClick,
|
|
||||||
scrollable = false,
|
|
||||||
maxHeight = "calc(100vh - 320px)",
|
|
||||||
enablePagination = false,
|
|
||||||
defaultPageSize = 10,
|
|
||||||
pageSizeOptions = [5, 10, 15, 20],
|
|
||||||
}: VersionTableProps<TData>) {
|
|
||||||
const [pagination, setPagination] = useState({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: defaultPageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data,
|
|
||||||
columns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
...(enablePagination && {
|
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}),
|
|
||||||
getRowId: (row: any) => row.id?.toString(),
|
|
||||||
enableRowSelection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onTableInit?.(table);
|
|
||||||
}, [table, onTableInit]);
|
|
||||||
|
|
||||||
const tableContent = (
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
<TableRow key={headerGroup.id}>
|
|
||||||
{headerGroup.headers.map((header) => (
|
|
||||||
<TableHead key={header.id}>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : table.getRowModel().rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={columns.length}>Không có dữ liệu.</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
onClick={() => onRowClick?.(row.original)}
|
|
||||||
className={onRowClick ? "cursor-pointer hover:bg-muted/50" : ""}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id}>
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scrollable) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<ScrollArea className="w-full" style={{ height: maxHeight }}>
|
|
||||||
{tableContent}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
{enablePagination && data.length > 0 && (
|
|
||||||
<CustomPagination
|
|
||||||
currentPage={table.getState().pagination.pageIndex + 1}
|
|
||||||
totalPages={table.getPageCount()}
|
|
||||||
totalItems={data.length}
|
|
||||||
itemsPerPage={table.getState().pagination.pageSize}
|
|
||||||
onPageChange={(page) => table.setPageIndex(page - 1)}
|
|
||||||
onPageSizeChange={(size) => table.setPageSize(size)}
|
|
||||||
pageSizeOptions={pageSizeOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-md border">{tableContent}</div>
|
|
||||||
{enablePagination && data.length > 0 && (
|
|
||||||
<CustomPagination
|
|
||||||
currentPage={table.getState().pagination.pageIndex + 1}
|
|
||||||
totalPages={table.getPageCount()}
|
|
||||||
totalItems={data.length}
|
|
||||||
itemsPerPage={table.getState().pagination.pageSize}
|
|
||||||
onPageChange={(page) => table.setPageIndex(page - 1)}
|
|
||||||
onPageSizeChange={(size) => table.setPageSize(size)}
|
|
||||||
pageSizeOptions={pageSizeOptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import * as React from "react"
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
import { Slot } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
|
||||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
|
||||||
return (
|
|
||||||
<ol
|
|
||||||
data-slot="breadcrumb-list"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-item"
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbLink({
|
|
||||||
asChild,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot.Root : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="breadcrumb-link"
|
|
||||||
className={cn("transition-colors hover:text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-page"
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("font-normal text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) {
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-slot="breadcrumb-separator"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreadcrumbEllipsis({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="breadcrumb-ellipsis"
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex size-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { PanelLeftIcon } from "lucide-react"
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/useMobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
164
src/components/upload-dialog.tsx
Normal file
164
src/components/upload-dialog.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { useForm, formOptions } from "@tanstack/react-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
|
||||||
|
interface UploadDialogProps {
|
||||||
|
onSubmit: (
|
||||||
|
fd: FormData,
|
||||||
|
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formOpts = formOptions({
|
||||||
|
defaultValues: { files: new DataTransfer().files, newVersion: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function UploadDialog({ onSubmit }: UploadDialogProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [uploadPercent, setUploadPercent] = useState(0);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
...formOpts,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
if (!value.newVersion || value.files.length === 0) {
|
||||||
|
toast.error("Vui lòng điền đầy đủ thông tin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUploading(true);
|
||||||
|
setUploadPercent(0);
|
||||||
|
setIsDone(false);
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
|
||||||
|
fd.append("Version", value.newVersion);
|
||||||
|
|
||||||
|
await onSubmit(fd, {
|
||||||
|
onUploadProgress: (e: AxiosProgressEvent) => {
|
||||||
|
if (e.total) {
|
||||||
|
const progress = Math.round((e.loaded * 100) / e.total);
|
||||||
|
setUploadPercent(progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsDone(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload error:", error);
|
||||||
|
toast.error("Upload thất bại!");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDialogClose = (open: boolean) => {
|
||||||
|
if (isUploading) return;
|
||||||
|
setIsOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setUploadPercent(0);
|
||||||
|
setIsDone(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleDialogClose}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>Tải lên phiên bản mới</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Cập nhật phiên bản</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form.Field name="newVersion">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label>Phiên bản</Label>
|
||||||
|
<Input
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="1.0.0"
|
||||||
|
disabled={isUploading || isDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<form.Field name="files">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label>File</Label>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".exe,.msi,.apk"
|
||||||
|
onChange={(e) =>
|
||||||
|
e.target.files && field.handleChange(e.target.files)
|
||||||
|
}
|
||||||
|
disabled={isUploading || isDone}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{(uploadPercent > 0 || isUploading || isDone) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
|
||||||
|
<span>{uploadPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={uploadPercent} className="w-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{!isDone ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleDialogClose(false)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
Hủy
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isUploading}>
|
||||||
|
{isUploading ? "Đang tải..." : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" onClick={() => handleDialogClose(false)}>
|
||||||
|
Hoàn tất
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/components/version-table.tsx
Normal file
87
src/components/version-table.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import {
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@tanstack/react-table";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
interface VersionTableProps<TData> {
|
||||||
|
data: TData[];
|
||||||
|
columns: ColumnDef<TData, any>[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onTableInit?: (table: any) => void; // <-- thêm
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionTable<TData>({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
isLoading,
|
||||||
|
onTableInit,
|
||||||
|
}: VersionTableProps<TData>) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (row: any) => row.id?.toString(),
|
||||||
|
enableRowSelection: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTableInit?.(table);
|
||||||
|
}, [table, onTableInit]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length}>Đang tải dữ liệu...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : table.getRowModel().rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length}>Không có dữ liệu.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,141 +1,33 @@
|
||||||
const isDev = import.meta.env.MODE === "development";
|
const isDev = import.meta.env.MODE === "development";
|
||||||
|
|
||||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, "");
|
|
||||||
|
|
||||||
export const BASE_URL = isDev
|
export const BASE_URL = isDev
|
||||||
? import.meta.env.VITE_API_URL_DEV
|
? import.meta.env.VITE_API_URL_DEV
|
||||||
: "/api";
|
: "/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 = {
|
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`,
|
|
||||||
PING: `${BASE_URL}/ping`,
|
|
||||||
CSRF_TOKEN: `${BASE_URL}/csrf-token`,
|
|
||||||
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: {
|
APP_VERSION: {
|
||||||
//agent and app api
|
|
||||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||||
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
UPLOAD: `${BASE_URL}/AppVersion/upload`,
|
||||||
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
GET_SOFTWARE: `${BASE_URL}/AppVersion/msifiles`,
|
||||||
|
|
||||||
//blacklist api
|
|
||||||
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
GET_BLACKLIST: `${BASE_URL}/AppVersion/blacklist`,
|
||||||
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
ADD_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/add`,
|
||||||
DELETE_BLACKLIST: (appId: number) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
|
DELETE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/remove/${appId}`,
|
||||||
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
UPDATE_BLACKLIST: (appId: string) => `${BASE_URL}/AppVersion/blacklist/update/${appId}`,
|
||||||
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
REQUEST_UPDATE_BLACKLIST: `${BASE_URL}/AppVersion/blacklist/request-update`,
|
||||||
|
|
||||||
//require file api
|
|
||||||
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
|
|
||||||
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
|
|
||||||
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
|
|
||||||
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
|
|
||||||
},
|
},
|
||||||
DEVICE_COMM: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
DOWNLOAD_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
||||||
INSTALL_MSI: (roomName: string) => `${BASE_URL}/DeviceComm/installmsi/${roomName}`,
|
|
||||||
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
GET_ALL_DEVICES: `${BASE_URL}/DeviceComm/alldevices`,
|
||||||
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
GET_ROOM_LIST: `${BASE_URL}/DeviceComm/rooms`,
|
||||||
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
GET_DEVICE_FROM_ROOM: (roomName: string) =>
|
||||||
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
`${BASE_URL}/DeviceComm/room/${roomName}`,
|
||||||
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
|
UPDATE_AGENT: (roomName: string) => `${BASE_URL}/DeviceComm/updateagent/${roomName}`,
|
||||||
UPDATE_BLACKLIST: (roomName: string) => `${BASE_URL}/DeviceComm/updateblacklist/${roomName}`,
|
|
||||||
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
SEND_COMMAND: (roomName: string) => `${BASE_URL}/DeviceComm/shellcommand/${roomName}`,
|
||||||
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
|
||||||
REQUEST_GET_CLIENT_FOLDER_STATUS: (roomName: string) =>
|
|
||||||
`${BASE_URL}/DeviceComm/folderstatuses/${roomName}`,
|
|
||||||
},
|
|
||||||
COMMAND: {
|
|
||||||
ADD_COMMAND: `${BASE_URL}/Command/add`,
|
|
||||||
GET_COMMANDS: `${BASE_URL}/Command/all`,
|
|
||||||
GET_COMMAND_BY_TYPES: (types: string) => `${BASE_URL}/Command/types/${types}`,
|
|
||||||
UPDATE_COMMAND: (commandId: number) => `${BASE_URL}/Command/update/${commandId}`,
|
|
||||||
DELETE_COMMAND: (commandId: number) => `${BASE_URL}/Command/delete/${commandId}`,
|
|
||||||
GET_SENSITIVE_COMMANDS: `${BASE_URL}/Command/sensitive`,
|
|
||||||
REQUEST_SEND_SENSITIVE_COMMAND: `${BASE_URL}/Command/send-sensitive`,
|
|
||||||
},
|
},
|
||||||
SSE_EVENTS: {
|
SSE_EVENTS: {
|
||||||
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
|
||||||
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
DEVICE_OFFLINE: `${BASE_URL}/Sse/events/offlineDevices`,
|
||||||
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
GET_PROCESSES_LISTS: `${BASE_URL}/Sse/events/processLists`,
|
||||||
GET_CLIENT_FOLDER_STATUS: `${BASE_URL}/Sse/events/clientFolderStatuses`,
|
|
||||||
},
|
},
|
||||||
PERMISSION: {
|
|
||||||
GET_LIST: `${BASE_URL}/Permission/list`,
|
|
||||||
GET_BY_CATEGORY: `${BASE_URL}/Permission/list-by-category`,
|
|
||||||
GET_BY_VALUE: (value: number) => `${BASE_URL}/Permission/${value}`,
|
|
||||||
SEED_FROM_ENUM: `${BASE_URL}/Permission/seed-from-enum`,
|
|
||||||
GET_DB_LIST: `${BASE_URL}/Permission/db-list`,
|
|
||||||
DELETE: (id: number) => `${BASE_URL}/Permission/${id}`,
|
|
||||||
},
|
|
||||||
ROLE: {
|
|
||||||
GET_LIST: `${BASE_URL}/Role/list`,
|
|
||||||
GET_BY_ID: (id: number) => `${BASE_URL}/Role/${id}`,
|
|
||||||
CREATE: `${BASE_URL}/Role/create`,
|
|
||||||
UPDATE: (id: number) => `${BASE_URL}/Role/update/${id}`,
|
|
||||||
DELETE: (id: number) => `${BASE_URL}/Role/${id}`,
|
|
||||||
GET_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/permissions`,
|
|
||||||
ASSIGN_PERMISSIONS: (id: number) => `${BASE_URL}/Role/${id}/assign-permissions`,
|
|
||||||
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
|
|
||||||
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
|
|
||||||
},
|
|
||||||
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`,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
// Re-export types from axios for convenience
|
|
||||||
export type { AxiosProgressEvent, AxiosRequestConfig, AxiosResponse, AxiosError } from "axios";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Axios instance với interceptor tự động gửi token
|
|
||||||
*/
|
|
||||||
const axiosInstance = axios.create();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request interceptor - Tự động thêm Authorization header
|
|
||||||
*/
|
|
||||||
axiosInstance.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
// Lấy token từ localStorage
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
|
|
||||||
// Nếu có token, thêm vào header Authorization
|
|
||||||
if (token) {
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response interceptor - Xử lý lỗi 401 (Unauthorized)
|
|
||||||
*/
|
|
||||||
axiosInstance.interceptors.response.use(
|
|
||||||
(response) => {
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
// Nếu nhận được 401, có thể redirect về trang login
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
// Có thể thêm logic redirect hoặc refresh token ở đây
|
|
||||||
console.warn("Unauthorized - Token may be expired or invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default axiosInstance;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* System-wide constants
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* System Admin priority value
|
|
||||||
* Priority = 0 means highest permission level (System Admin)
|
|
||||||
* Lower priority number = Higher permission level
|
|
||||||
*/
|
|
||||||
export const SYSTEM_ADMIN_PRIORITY = 0;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
import type { SelectItem } from "@/components/dialogs/select-dialog";
|
|
||||||
|
|
||||||
export function mapRoomsToSelectItems(rooms: Room[]): SelectItem[] {
|
|
||||||
return rooms.map((room) => ({
|
|
||||||
label: `${room.name} (${room.numberOfDevices} máy, ${room.numberOfOfflineDevices} offline)`,
|
|
||||||
value: room.name,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a priority value indicates System Admin
|
|
||||||
* @param priority - The priority value to check
|
|
||||||
* @returns true if the priority is System Admin (0), false otherwise
|
|
||||||
*/
|
|
||||||
export function isSystemAdminPriority(priority: number): boolean {
|
|
||||||
return priority === SYSTEM_ADMIN_PRIORITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a priority has higher permission than another
|
|
||||||
* Lower number = Higher permission (System Admin = 0 is highest)
|
|
||||||
* @param priority1 - First priority to compare
|
|
||||||
* @param priority2 - Second priority to compare
|
|
||||||
* @returns true if priority1 has higher or equal permission than priority2
|
|
||||||
*/
|
|
||||||
export function hasHigherOrEqualPriority(priority1: number, priority2: number): boolean {
|
|
||||||
return priority1 <= priority2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare two priorities
|
|
||||||
* @param priority1 - First priority to compare
|
|
||||||
* @param priority2 - Second priority to compare
|
|
||||||
* @returns -1 if priority1 > priority2, 0 if equal, 1 if priority1 < priority2
|
|
||||||
*/
|
|
||||||
export function comparePriorities(priority1: number, priority2: number): -1 | 0 | 1 {
|
|
||||||
if (priority1 < priority2) return 1; // Lower number = higher permission
|
|
||||||
if (priority1 > priority2) return -1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a human-readable priority label
|
|
||||||
* @param priority - The priority value
|
|
||||||
* @returns A label for the priority
|
|
||||||
*/
|
|
||||||
export function getPriorityLabel(priority: number): string {
|
|
||||||
if (priority === SYSTEM_ADMIN_PRIORITY) {
|
|
||||||
return "System Admin (Highest)";
|
|
||||||
}
|
|
||||||
return `Priority ${priority}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
// Auth Queries
|
|
||||||
export * from "./useAuthQueries";
|
|
||||||
|
|
||||||
// App Version Queries
|
|
||||||
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";
|
|
||||||
|
|
||||||
// Role Queries
|
|
||||||
export * from "./useRoleQueries";
|
|
||||||
// User Queries
|
|
||||||
export * from "./useUserQueries";
|
|
||||||
|
|
@ -1,186 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as appVersionService from "@/services/app-version.service";
|
|
||||||
import type { AxiosProgressEvent } from "axios";
|
|
||||||
import type { Version } from "@/types/file";
|
|
||||||
|
|
||||||
const APP_VERSION_QUERY_KEYS = {
|
|
||||||
all: ["app-version"] as const,
|
|
||||||
agentVersion: () => [...APP_VERSION_QUERY_KEYS.all, "agent"] as const,
|
|
||||||
softwareList: () => [...APP_VERSION_QUERY_KEYS.all, "software"] as const,
|
|
||||||
blacklist: () => [...APP_VERSION_QUERY_KEYS.all, "blacklist"] as const,
|
|
||||||
requiredFiles: () => [...APP_VERSION_QUERY_KEYS.all, "required-files"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách phiên bản agent
|
|
||||||
*/
|
|
||||||
export function useGetAgentVersion(enabled = true) {
|
|
||||||
return useQuery<Version[]>({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.agentVersion(),
|
|
||||||
queryFn: () => appVersionService.getAgentVersion(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000, // 1 minute
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách phần mềm
|
|
||||||
*/
|
|
||||||
export function useGetSoftwareList(enabled = true) {
|
|
||||||
return useQuery<Version[]>({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
|
||||||
queryFn: () => appVersionService.getSoftwareList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để upload file
|
|
||||||
*/
|
|
||||||
export function useUploadSoftware() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: {
|
|
||||||
formData: FormData;
|
|
||||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
|
||||||
}) => appVersionService.uploadSoftware(data.formData, data.onUploadProgress),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Invalidate software list
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách blacklist
|
|
||||||
*/
|
|
||||||
export function useGetBlacklist(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
|
|
||||||
queryFn: () => appVersionService.getBlacklist(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để thêm vào blacklist
|
|
||||||
*/
|
|
||||||
export function useAddBlacklist() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: any) => appVersionService.addBlacklist(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa khỏi blacklist
|
|
||||||
*/
|
|
||||||
export function useDeleteBlacklist() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (appId: number) => appVersionService.deleteBlacklist(appId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cập nhật blacklist
|
|
||||||
*/
|
|
||||||
export function useUpdateBlacklist() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({ appId, data }: { appId: string; data: any }) =>
|
|
||||||
appVersionService.updateBlacklist(appId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.blacklist(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để yêu cầu cập nhật blacklist
|
|
||||||
*/
|
|
||||||
export function useRequestUpdateBlacklist() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: any) => appVersionService.requestUpdateBlacklist(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách file bắt buộc
|
|
||||||
*/
|
|
||||||
export function useGetRequiredFiles(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
|
|
||||||
queryFn: () => appVersionService.getRequiredFiles(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để thêm file bắt buộc
|
|
||||||
*/
|
|
||||||
export function useAddRequiredFile() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: any) => appVersionService.addRequiredFile(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa file bắt buộc
|
|
||||||
*/
|
|
||||||
export function useDeleteRequiredFile() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa file
|
|
||||||
*/
|
|
||||||
export function useDeleteFile() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as authService from "@/services/auth.service";
|
|
||||||
import type { CreateAccountRequest } from "@/types/auth";
|
|
||||||
import type { LoginResquest, LoginResponse } from "@/types/auth";
|
|
||||||
|
|
||||||
const AUTH_QUERY_KEYS = {
|
|
||||||
all: ["auth"] as const,
|
|
||||||
ping: () => [...AUTH_QUERY_KEYS.all, "ping"] as const,
|
|
||||||
csrfToken: () => [...AUTH_QUERY_KEYS.all, "csrf-token"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để đăng nhập
|
|
||||||
*/
|
|
||||||
export function useLogin() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<LoginResponse, any, LoginResquest>({
|
|
||||||
mutationFn: (credentials) => authService.login(credentials),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
// Lưu vào localStorage
|
|
||||||
if (data.token) {
|
|
||||||
localStorage.setItem("token", data.token);
|
|
||||||
localStorage.setItem("username", data.username || "");
|
|
||||||
localStorage.setItem("name", data.name || "");
|
|
||||||
localStorage.setItem("acs", JSON.stringify(data.access || []));
|
|
||||||
localStorage.setItem("role", data.role?.roleName || "");
|
|
||||||
localStorage.setItem("priority", String(data.role?.priority || "-1"));
|
|
||||||
}
|
|
||||||
// Invalidate ping query
|
|
||||||
queryClient.invalidateQueries({ queryKey: AUTH_QUERY_KEYS.ping() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để đăng xuất
|
|
||||||
*/
|
|
||||||
export function useLogout() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => authService.logout(),
|
|
||||||
onSuccess: () => {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
localStorage.removeItem("username");
|
|
||||||
localStorage.removeItem("name");
|
|
||||||
localStorage.removeItem("acs");
|
|
||||||
localStorage.removeItem("role");
|
|
||||||
localStorage.removeItem("priority");
|
|
||||||
// Clear all queries
|
|
||||||
queryClient.clear();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để kiểm tra phiên đăng nhập
|
|
||||||
*/
|
|
||||||
export function usePing(token?: string, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: AUTH_QUERY_KEYS.ping(),
|
|
||||||
queryFn: () => authService.ping(token),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy CSRF token
|
|
||||||
*/
|
|
||||||
export function useGetCsrfToken(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: AUTH_QUERY_KEYS.csrfToken(),
|
|
||||||
queryFn: () => authService.getCsrfToken(),
|
|
||||||
enabled,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để thay đổi mật khẩu
|
|
||||||
*/
|
|
||||||
export function useChangePassword() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { currentPassword: string; newPassword: string }) =>
|
|
||||||
authService.changePassword(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để admin thay đổi mật khẩu user khác
|
|
||||||
*/
|
|
||||||
export function useChangePasswordAdmin() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: { username: string; newPassword: string }) =>
|
|
||||||
authService.changePasswordAdmin(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để tạo tài khoản mới
|
|
||||||
*/
|
|
||||||
export function useCreateAccount() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: CreateAccountRequest) => authService.createAccount(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
// Có thể invalidate user list query nếu có
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as commandService from "@/services/command.service";
|
|
||||||
|
|
||||||
const COMMAND_QUERY_KEYS = {
|
|
||||||
all: ["commands"] as const,
|
|
||||||
list: () => [...COMMAND_QUERY_KEYS.all, "list"] as const,
|
|
||||||
detail: (id: number) => [...COMMAND_QUERY_KEYS.all, "detail", id] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách lệnh
|
|
||||||
*/
|
|
||||||
export function useGetCommandList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: COMMAND_QUERY_KEYS.list(),
|
|
||||||
queryFn: () => commandService.getCommandList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//Hook để lấy lệnh theo loại
|
|
||||||
export function useGetCommandsByTypes(types: string, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [...COMMAND_QUERY_KEYS.all, "by-types", types],
|
|
||||||
queryFn: () => commandService.getCommandsByTypes(types),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để thêm lệnh mới
|
|
||||||
*/
|
|
||||||
export function useAddCommand() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: any) => commandService.addCommand(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: COMMAND_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cập nhật lệnh
|
|
||||||
*/
|
|
||||||
export function useUpdateCommand() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
commandId,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
commandId: number;
|
|
||||||
data: any;
|
|
||||||
}) => commandService.updateCommand(commandId, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: COMMAND_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa lệnh
|
|
||||||
*/
|
|
||||||
export function useDeleteCommand() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (commandId: number) => commandService.deleteCommand(commandId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: COMMAND_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách lệnh nhạy cảm
|
|
||||||
*/
|
|
||||||
export function useGetSensitiveCommands(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
|
|
||||||
queryFn: () => commandService.getSensitiveCommands(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để gửi lệnh nhạy cảm
|
|
||||||
*/
|
|
||||||
export function useExecuteSensitiveCommand() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
command,
|
|
||||||
password,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
command: any;
|
|
||||||
password: string;
|
|
||||||
}) =>
|
|
||||||
// API expects a SensitiveCommandRequest with PascalCase keys
|
|
||||||
commandService.requestSendSensitiveCommand({
|
|
||||||
Command: command,
|
|
||||||
Password: password,
|
|
||||||
RoomName: roomName,
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: [...COMMAND_QUERY_KEYS.all, "sensitive"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
||||||
const DEVICE_COMM_QUERY_KEYS = {
|
|
||||||
all: ["device-comm"] as const,
|
|
||||||
allDevices: () => [...DEVICE_COMM_QUERY_KEYS.all, "all"] as const,
|
|
||||||
roomList: () => [...DEVICE_COMM_QUERY_KEYS.all, "rooms"] as const,
|
|
||||||
devicesInRoom: (roomName: string) =>
|
|
||||||
[...DEVICE_COMM_QUERY_KEYS.all, "room", roomName] as const,
|
|
||||||
clientFolderStatus: (roomName: string) =>
|
|
||||||
[...DEVICE_COMM_QUERY_KEYS.all, "folder-status", roomName] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy tất cả thiết bị
|
|
||||||
*/
|
|
||||||
export function useGetAllDevices(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.allDevices(),
|
|
||||||
queryFn: () => deviceCommService.getAllDevices(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách phòng
|
|
||||||
*/
|
|
||||||
export function useGetRoomList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
|
|
||||||
queryFn: () => deviceCommService.getRoomList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách thiết bị trong phòng
|
|
||||||
*/
|
|
||||||
export function useGetDeviceFromRoom(roomName?: string, enabled = true) {
|
|
||||||
return useQuery<DeviceHealthCheck[]>({
|
|
||||||
queryKey: roomName ? DEVICE_COMM_QUERY_KEYS.devicesInRoom(roomName) : ["disabled"],
|
|
||||||
queryFn: () =>
|
|
||||||
roomName ? deviceCommService.getDeviceFromRoom(roomName) : Promise.reject("No room"),
|
|
||||||
enabled: enabled && !!roomName,
|
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để tải file
|
|
||||||
*/
|
|
||||||
export function useDownloadFiles() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
data: any;
|
|
||||||
}) => deviceCommService.downloadFiles(roomName, data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cài đặt MSI
|
|
||||||
*/
|
|
||||||
export function useInstallMsi() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
data: any;
|
|
||||||
}) => deviceCommService.installMsi(roomName, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cập nhật agent
|
|
||||||
*/
|
|
||||||
export function useUpdateAgent() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
data: any;
|
|
||||||
}) => deviceCommService.updateAgent(roomName, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cập nhật blacklist
|
|
||||||
*/
|
|
||||||
export function useUpdateDeviceBlacklist() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
data: any;
|
|
||||||
}) => deviceCommService.updateBlacklist(roomName, data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để gửi lệnh shell
|
|
||||||
*/
|
|
||||||
export function useSendCommand() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roomName,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
roomName: string;
|
|
||||||
data: any;
|
|
||||||
}) => deviceCommService.sendCommand(roomName, data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để thay đổi phòng của thiết bị
|
|
||||||
*/
|
|
||||||
export function useChangeDeviceRoom() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: any) => deviceCommService.changeDeviceRoom(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy trạng thái folder client
|
|
||||||
*/
|
|
||||||
export function useGetClientFolderStatus(roomName?: string, enabled = true) {
|
|
||||||
return useQuery<ClientFolderStatus[]>({
|
|
||||||
queryKey: roomName
|
|
||||||
? DEVICE_COMM_QUERY_KEYS.clientFolderStatus(roomName)
|
|
||||||
: ["disabled"],
|
|
||||||
queryFn: () =>
|
|
||||||
roomName
|
|
||||||
? deviceCommService.getClientFolderStatus(roomName)
|
|
||||||
: Promise.reject("No room"),
|
|
||||||
enabled: enabled && !!roomName,
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get folder status for a single device. The hook will fetch the
|
|
||||||
* folder status list for the device's room and return the matching entry
|
|
||||||
* for the provided `deviceId`.
|
|
||||||
*/
|
|
||||||
export function useGetClientFolderStatusForDevice(
|
|
||||||
deviceId?: string,
|
|
||||||
roomName?: string,
|
|
||||||
enabled = true
|
|
||||||
) {
|
|
||||||
return useQuery<ClientFolderStatus | undefined>({
|
|
||||||
queryKey: deviceId
|
|
||||||
? [...DEVICE_COMM_QUERY_KEYS.all, "folder-status-device", deviceId]
|
|
||||||
: ["disabled"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!roomName) return Promise.reject("No room");
|
|
||||||
const list = await deviceCommService.getClientFolderStatus(roomName);
|
|
||||||
if (!Array.isArray(list)) return undefined;
|
|
||||||
return list.find((s: ClientFolderStatus) => s.deviceId === deviceId);
|
|
||||||
},
|
|
||||||
enabled: enabled && !!deviceId && !!roomName,
|
|
||||||
staleTime: 30 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as permissionService from "@/services/permission.service";
|
|
||||||
|
|
||||||
export const PERMISSION_QUERY_KEYS = {
|
|
||||||
all: ["permissions"] as const,
|
|
||||||
list: () => [...PERMISSION_QUERY_KEYS.all, "list"] as const,
|
|
||||||
dbList: () => [...PERMISSION_QUERY_KEYS.all, "db-list"] as const,
|
|
||||||
byCategory: () => [...PERMISSION_QUERY_KEYS.all, "by-category"] as const,
|
|
||||||
detail: (value: number) => [...PERMISSION_QUERY_KEYS.all, "detail", value] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách permission từ enum
|
|
||||||
*/
|
|
||||||
export function useGetPermissionList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.list(),
|
|
||||||
queryFn: () => permissionService.getPermissionList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy permission theo category
|
|
||||||
*/
|
|
||||||
export function useGetPermissionByCategory(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.byCategory(),
|
|
||||||
queryFn: () => permissionService.getPermissionByCategory(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy chi tiết permission theo value
|
|
||||||
*/
|
|
||||||
export function useGetPermissionByValue(value: number, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.detail(value),
|
|
||||||
queryFn: () => permissionService.getPermissionByValue(value),
|
|
||||||
enabled: enabled && value > 0,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách permission từ database
|
|
||||||
*/
|
|
||||||
export function useGetPermissionDbList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.dbList(),
|
|
||||||
queryFn: () => permissionService.getPermissionDbList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để seed permission từ enum vào DB
|
|
||||||
*/
|
|
||||||
export function useSeedPermissionFromEnum() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => permissionService.seedPermissionFromEnum(),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa permission
|
|
||||||
*/
|
|
||||||
export function useDeletePermission() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: number) => permissionService.deletePermission(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: PERMISSION_QUERY_KEYS.all,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as roleService from "@/services/role.service";
|
|
||||||
import type { TCreateRoleRequestBody } from "@/types/role";
|
|
||||||
|
|
||||||
export const ROLE_QUERY_KEYS = {
|
|
||||||
all: ["roles"] as const,
|
|
||||||
list: () => [...ROLE_QUERY_KEYS.all, "list"] as const,
|
|
||||||
detail: (id: number) => [...ROLE_QUERY_KEYS.all, "detail", id] as const,
|
|
||||||
permissions: (id: number) => [...ROLE_QUERY_KEYS.all, "permissions", id] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách roles
|
|
||||||
*/
|
|
||||||
export function useGetRoleList(enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.list(),
|
|
||||||
queryFn: () => roleService.getRoleList(),
|
|
||||||
enabled,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy chi tiết role theo ID
|
|
||||||
*/
|
|
||||||
export function useGetRoleById(id: number, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.detail(id),
|
|
||||||
queryFn: () => roleService.getRoleById(id),
|
|
||||||
enabled: enabled && id > 0,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách permissions của role
|
|
||||||
*/
|
|
||||||
export function useGetRolePermissions(id: number, enabled = true) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.permissions(id),
|
|
||||||
queryFn: () => roleService.getRolePermissions(id),
|
|
||||||
enabled: enabled && id > 0,
|
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để tạo role mới
|
|
||||||
*/
|
|
||||||
export function useCreateRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (data: TCreateRoleRequestBody) => roleService.createRole(data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để cập nhật role
|
|
||||||
*/
|
|
||||||
export function useUpdateRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
id,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
id: number;
|
|
||||||
data: Partial<TCreateRoleRequestBody>;
|
|
||||||
}) => roleService.updateRole(id, data),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.detail(variables.id),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để xóa role
|
|
||||||
*/
|
|
||||||
export function useDeleteRole() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: number) => roleService.deleteRole(id),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.list(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để gán permissions cho role
|
|
||||||
*/
|
|
||||||
export function useAssignRolePermissions() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roleId,
|
|
||||||
permissionIds,
|
|
||||||
}: {
|
|
||||||
roleId: number;
|
|
||||||
permissionIds: number[];
|
|
||||||
}) => roleService.assignRolePermissions(roleId, permissionIds),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để bật/tắt một permission của role
|
|
||||||
*/
|
|
||||||
export function useToggleRolePermission() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: ({
|
|
||||||
roleId,
|
|
||||||
permissionId,
|
|
||||||
isChecked,
|
|
||||||
}: {
|
|
||||||
roleId: number;
|
|
||||||
permissionId: number;
|
|
||||||
isChecked: boolean;
|
|
||||||
}) => roleService.toggleRolePermission(roleId, permissionId, isChecked),
|
|
||||||
onSuccess: (_, variables) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ROLE_QUERY_KEYS.permissions(variables.roleId),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import * as userService from "@/services/user.service";
|
|
||||||
import type {
|
|
||||||
UserProfile,
|
|
||||||
UpdateUserInfoRequest,
|
|
||||||
UpdateUserRoleRequest,
|
|
||||||
} from "@/types/user-profile";
|
|
||||||
|
|
||||||
const USER_QUERY_KEYS = {
|
|
||||||
all: ["users"] as const,
|
|
||||||
list: () => [...USER_QUERY_KEYS.all, "list"] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để lấy danh sách thông tin người dùng
|
|
||||||
*/
|
|
||||||
export function useGetUsersInfo(enabled = true) {
|
|
||||||
return useQuery<UserProfile[]>({
|
|
||||||
queryKey: USER_QUERY_KEYS.list(),
|
|
||||||
queryFn: () => userService.getUsersInfo(),
|
|
||||||
enabled,
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { sleep } from "@/lib/utils";
|
|
||||||
import { PermissionEnum } from "@/types/permission";
|
|
||||||
import { SYSTEM_ADMIN_PRIORITY } from "@/config/constants";
|
|
||||||
import React, { useContext, useEffect, useMemo } from "react";
|
|
||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
export interface IAuthContext {
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
setAuthenticated: (value: boolean) => void;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
login: (username: string) => void;
|
|
||||||
username: string;
|
|
||||||
token: string;
|
|
||||||
name: string;
|
|
||||||
acs: number[];
|
|
||||||
hasPermission: (permission: PermissionEnum) => boolean;
|
|
||||||
isSystemAdmin: () => boolean;
|
|
||||||
role: {
|
|
||||||
roleName: string;
|
|
||||||
priority: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = React.createContext<IAuthContext | null>(null);
|
|
||||||
|
|
||||||
const key = "computersmanagement.auth.user";
|
|
||||||
|
|
||||||
function getStoredUser() {
|
|
||||||
return localStorage.getItem(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoredUser(user: string | null) {
|
|
||||||
if (user) {
|
|
||||||
localStorage.setItem(key, user);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [user, setUser] = useState<string>(getStoredUser() || "");
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(!!user);
|
|
||||||
const token = localStorage.getItem("token") || "";
|
|
||||||
const name = localStorage.getItem("name") || "";
|
|
||||||
const acsString = localStorage.getItem("acs");
|
|
||||||
const acs = useMemo(() => (acsString ? acsString.split(",").map(Number) : []), [acsString]);
|
|
||||||
const roleName = localStorage.getItem("role") || "";
|
|
||||||
const priority = localStorage.getItem("priority") || "-1";
|
|
||||||
|
|
||||||
const setAuthenticated = useCallback((value: boolean) => {
|
|
||||||
setIsAuthenticated(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback((username: string) => {
|
|
||||||
setStoredUser(username);
|
|
||||||
setUser(username);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasPermission = useCallback(
|
|
||||||
(permission: PermissionEnum) => {
|
|
||||||
return acs.some((a) => a === permission);
|
|
||||||
},
|
|
||||||
[acs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const isSystemAdmin = useCallback(() => {
|
|
||||||
return Number(priority) === SYSTEM_ADMIN_PRIORITY;
|
|
||||||
}, [priority]);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
await sleep(250);
|
|
||||||
setAuthenticated(false);
|
|
||||||
setStoredUser("");
|
|
||||||
setUser("");
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
localStorage.removeItem("name");
|
|
||||||
localStorage.removeItem("acs");
|
|
||||||
localStorage.removeItem("role");
|
|
||||||
localStorage.removeItem("priority");
|
|
||||||
}, [setAuthenticated]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUser(getStoredUser() || "");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider
|
|
||||||
value={{
|
|
||||||
isAuthenticated,
|
|
||||||
setAuthenticated,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
username: user,
|
|
||||||
token,
|
|
||||||
name,
|
|
||||||
acs,
|
|
||||||
role: { roleName, priority: Number(priority) },
|
|
||||||
hasPermission,
|
|
||||||
isSystemAdmin
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export function useAuth() {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useAuth must be used within an AuthProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
53
src/hooks/useMutationData.ts
Normal file
53
src/hooks/useMutationData.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import axios, { type Method } from "axios";
|
||||||
|
|
||||||
|
type MutationDataOptions<TInput, TOutput> = {
|
||||||
|
url: string;
|
||||||
|
method?: Method;
|
||||||
|
onSuccess?: (data: TOutput) => void;
|
||||||
|
onError?: (error: any) => void;
|
||||||
|
invalidate?: string[][];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMutationData<TInput = any, TOutput = any>({
|
||||||
|
url,
|
||||||
|
method = "POST",
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
invalidate = [],
|
||||||
|
}: MutationDataOptions<TInput, TOutput>) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
TOutput,
|
||||||
|
any,
|
||||||
|
{ data: TInput; url?: string; config?: any; method?: Method }
|
||||||
|
>({
|
||||||
|
mutationFn: async ({
|
||||||
|
data,
|
||||||
|
config,
|
||||||
|
url: customUrl,
|
||||||
|
method: customMethod,
|
||||||
|
}) => {
|
||||||
|
const isFormData = data instanceof FormData;
|
||||||
|
|
||||||
|
const response = await axios.request({
|
||||||
|
url: customUrl ?? url,
|
||||||
|
method: customMethod ?? method,
|
||||||
|
data,
|
||||||
|
headers: {
|
||||||
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
invalidate.forEach((key) =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: key })
|
||||||
|
);
|
||||||
|
onSuccess?.(data);
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
src/hooks/useQueryData.ts
Normal file
26
src/hooks/useQueryData.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
type QueryDataOptions<T> = {
|
||||||
|
queryKey: string[];
|
||||||
|
url: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
select?: (data: any) => T;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQueryData<T = any>({
|
||||||
|
queryKey,
|
||||||
|
url,
|
||||||
|
params,
|
||||||
|
select,
|
||||||
|
enabled = true,
|
||||||
|
}: QueryDataOptions<T>) {
|
||||||
|
return useQuery<T>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => axios.get(url, { params }).then((res) => res.data),
|
||||||
|
select,
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AppSidebar } from "@/components/sidebars/app-sidebar";
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
import {
|
import {
|
||||||
SidebarProvider,
|
SidebarProvider,
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { Building } from "lucide-react";
|
import { Home, Building, AppWindow, Terminal, CircleX } from "lucide-react";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
type AppLayoutProps = {
|
type AppLayoutProps = {
|
||||||
|
|
@ -14,10 +16,84 @@ type AppLayoutProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppLayout({ children }: AppLayoutProps) {
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handlePrefetchAgents = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["agent-version"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handlePrefetchSofware = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["software-version"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION.GET_SOFTWARE).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefetchRooms = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["room-list"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrefetchBannedSoftware = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["blacklist"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(BASE_URL + API_ENDPOINTS.APP_VERSION).then((res) =>
|
||||||
|
res.json()
|
||||||
|
),
|
||||||
|
staleTime: 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ title: "Dashboard", to: "/", icon: Home },
|
||||||
|
{
|
||||||
|
title: "Danh sách phòng",
|
||||||
|
to: "/room",
|
||||||
|
icon: Building,
|
||||||
|
onPointerEnter: handlePrefetchRooms,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Quản lý Agent",
|
||||||
|
to: "/agent",
|
||||||
|
icon: AppWindow,
|
||||||
|
onPointerEnter: handlePrefetchAgents,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Quản lý phần mềm",
|
||||||
|
to: "/apps",
|
||||||
|
icon: AppWindow,
|
||||||
|
onPointerEnter: handlePrefetchSofware,
|
||||||
|
},
|
||||||
|
{ title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
|
||||||
|
{
|
||||||
|
title: "Danh sách đen",
|
||||||
|
to: "/blacklist",
|
||||||
|
icon: CircleX,
|
||||||
|
onPointerEnter: handlePrefetchBannedSoftware,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="flex min-h-screen w-full bg-background">
|
<div className="flex min-h-screen w-full bg-background">
|
||||||
<AppSidebar />
|
<AppSidebar items={items} />
|
||||||
<SidebarInset className="flex-1">
|
<SidebarInset className="flex-1">
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/40 bg-background/95 backdrop-blur px-4 lg:hidden supports-[backdrop-filter]:bg-background/60">
|
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/40 bg-background/95 backdrop-blur px-4 lg:hidden supports-[backdrop-filter]:bg-background/60">
|
||||||
<SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />
|
<SidebarTrigger className="-ml-1 hover:bg-accent/50 rounded-lg p-2 transition-colors" />
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,6 @@
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx"
|
||||||
import { format } from "date-fns";
|
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_DATE_FORMAT = "dd/MM/yyyy";
|
|
||||||
|
|
||||||
|
|
||||||
export function getCurrentTimeUTC(): string {
|
|
||||||
const date = new Date();
|
|
||||||
const month = (date.getUTCMonth() + 1).toString().padStart(2, "0");
|
|
||||||
const day = date.getUTCDate().toString().padStart(2, "0");
|
|
||||||
const year = date.getUTCFullYear();
|
|
||||||
const hour = date.getUTCHours().toString().padStart(2, "0");
|
|
||||||
const min = date.getUTCMinutes().toString().padStart(2, "0");
|
|
||||||
|
|
||||||
return `${day}/${month}/${year} ${hour}:${min}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDate(
|
|
||||||
date: string | Date | undefined | null,
|
|
||||||
formatTemplate = DEFAULT_DATE_FORMAT
|
|
||||||
) {
|
|
||||||
if (date == undefined) return "";
|
|
||||||
if (date == null) return "";
|
|
||||||
return format(new Date(date), formatTemplate, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
84
src/main.tsx
84
src/main.tsx
|
|
@ -1,24 +1,25 @@
|
||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import "./index.css";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
|
import useAuthToken from "./hooks/useAuthtoken";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
// Import the generated route tree
|
// Import the generated route tree
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import axios from "axios";
|
import "./styles.css";
|
||||||
import { AuthProvider, useAuth } from "@/hooks/useAuth";
|
|
||||||
import { toast, Toaster } from "sonner";
|
const auth = useAuthToken.getState();
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
// Create a new router instance
|
// Create a new router instance
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
context: { auth },
|
||||||
defaultPreload: "intent",
|
defaultPreload: "intent",
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
context: {
|
defaultStructuralSharing: true,
|
||||||
auth: undefined!, // This will be set after we initialize the auth store
|
defaultPreloadStaleTime: 0,
|
||||||
queryClient: undefined!
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the router instance for type safety
|
// Register the router instance for type safety
|
||||||
|
|
@ -26,61 +27,18 @@ declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router;
|
router: typeof router;
|
||||||
}
|
}
|
||||||
interface HistoryState {
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function InnerApp() {
|
|
||||||
const auth = useAuth();
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: (failureCount, error: unknown) => {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
if (error.response?.data.message != null) {
|
|
||||||
toast.error("Không có quyền truy cập!");
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
auth.logout();
|
|
||||||
queryClient.invalidateQueries();
|
|
||||||
queryClient.clear();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return failureCount < 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RouterProvider router={router} context={{ auth, queryClient }} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<InnerApp />
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the app
|
// Render the app
|
||||||
const rootElement = document.getElementById("app");
|
const rootElement = document.getElementById("app");
|
||||||
|
if (rootElement && !rootElement.innerHTML) {
|
||||||
if (!rootElement) {
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
throw new Error("Failed to find the root element");
|
root.render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{" "}
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
|
||||||
root.render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
<Toaster richColors />
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -9,33 +9,22 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
|
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
|
||||||
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
|
import { Route as AuthenticatedDeviceIndexRouteImport } from './routes/_authenticated/device/index'
|
||||||
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
|
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
|
||||||
import { Route as AuthRemoteControlIndexRouteImport } from './routes/_auth/remote-control/index'
|
import { Route as AuthenticatedBlacklistIndexRouteImport } from './routes/_authenticated/blacklist/index'
|
||||||
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
|
||||||
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
|
||||||
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
|
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
|
||||||
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
|
import { Route as AuthenticatedRoomRoomNameIndexRouteImport } from './routes/_authenticated/room/$roomName/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'
|
|
||||||
import { Route as AuthUserCreateIndexRouteImport } from './routes/_auth/user/create/index'
|
|
||||||
import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms/$roomName/index'
|
|
||||||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
|
||||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/index'
|
|
||||||
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
|
|
||||||
import { Route as 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 AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||||
|
id: '/_authenticated',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
|
|
@ -45,215 +34,86 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthUserIndexRoute = AuthUserIndexRouteImport.update({
|
const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
|
||||||
id: '/user/',
|
id: '/room/',
|
||||||
path: '/user/',
|
path: '/room/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthRoomsIndexRoute = AuthRoomsIndexRouteImport.update({
|
const AuthenticatedDeviceIndexRoute =
|
||||||
id: '/rooms/',
|
AuthenticatedDeviceIndexRouteImport.update({
|
||||||
path: '/rooms/',
|
id: '/device/',
|
||||||
getParentRoute: () => AuthRoute,
|
path: '/device/',
|
||||||
} as any)
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
|
} as any)
|
||||||
id: '/role/',
|
const AuthenticatedCommandIndexRoute =
|
||||||
path: '/role/',
|
AuthenticatedCommandIndexRouteImport.update({
|
||||||
getParentRoute: () => AuthRoute,
|
id: '/command/',
|
||||||
} as any)
|
path: '/command/',
|
||||||
const AuthRemoteControlIndexRoute = AuthRemoteControlIndexRouteImport.update({
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
id: '/remote-control/',
|
} as any)
|
||||||
path: '/remote-control/',
|
const AuthenticatedBlacklistIndexRoute =
|
||||||
getParentRoute: () => AuthRoute,
|
AuthenticatedBlacklistIndexRouteImport.update({
|
||||||
} as any)
|
id: '/blacklist/',
|
||||||
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
|
path: '/blacklist/',
|
||||||
id: '/device/',
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
path: '/device/',
|
} as any)
|
||||||
getParentRoute: () => AuthRoute,
|
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
|
||||||
} as any)
|
|
||||||
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
|
|
||||||
id: '/dashboard/',
|
|
||||||
path: '/dashboard/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthCommandsIndexRoute = AuthCommandsIndexRouteImport.update({
|
|
||||||
id: '/commands/',
|
|
||||||
path: '/commands/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
|
|
||||||
id: '/blacklists/',
|
|
||||||
path: '/blacklists/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
|
|
||||||
id: '/audits/',
|
|
||||||
path: '/audits/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
|
|
||||||
id: '/apps/',
|
id: '/apps/',
|
||||||
path: '/apps/',
|
path: '/apps/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthAgentIndexRoute = AuthAgentIndexRouteImport.update({
|
const AuthenticatedAgentIndexRoute = AuthenticatedAgentIndexRouteImport.update({
|
||||||
id: '/agent/',
|
id: '/agent/',
|
||||||
path: '/agent/',
|
path: '/agent/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const authLoginIndexRoute = authLoginIndexRouteImport.update({
|
const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({
|
||||||
id: '/(auth)/login/',
|
id: '/login/',
|
||||||
path: '/login/',
|
path: '/login/',
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthUserCreateIndexRoute = AuthUserCreateIndexRouteImport.update({
|
|
||||||
id: '/user/create/',
|
|
||||||
path: '/user/create/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthRoomsRoomNameIndexRoute = AuthRoomsRoomNameIndexRouteImport.update({
|
const AuthenticatedRoomRoomNameIndexRoute =
|
||||||
id: '/rooms/$roomName/',
|
AuthenticatedRoomRoomNameIndexRouteImport.update({
|
||||||
path: '/rooms/$roomName/',
|
id: '/room/$roomName/',
|
||||||
getParentRoute: () => AuthRoute,
|
path: '/room/$roomName/',
|
||||||
} as any)
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
const AuthRoleCreateIndexRoute = AuthRoleCreateIndexRouteImport.update({
|
|
||||||
id: '/role/create/',
|
|
||||||
path: '/role/create/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthProfileChangePasswordIndexRoute =
|
|
||||||
AuthProfileChangePasswordIndexRouteImport.update({
|
|
||||||
id: '/profile/change-password/',
|
|
||||||
path: '/profile/change-password/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
} as any)
|
||||||
const AuthProfileUserNameIndexRoute =
|
|
||||||
AuthProfileUserNameIndexRouteImport.update({
|
|
||||||
id: '/profile/$userName/',
|
|
||||||
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/',
|
|
||||||
path: '/user/change-password/$userName/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthRoomsRoomNameFolderStatusIndexRoute =
|
|
||||||
AuthRoomsRoomNameFolderStatusIndexRouteImport.update({
|
|
||||||
id: '/rooms/$roomName/folder-status/',
|
|
||||||
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/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/audits': typeof AuthAuditsIndexRoute
|
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/blacklists': typeof AuthBlacklistsIndexRoute
|
'/command': typeof AuthenticatedCommandIndexRoute
|
||||||
'/commands': typeof AuthCommandsIndexRoute
|
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
'/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 {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof AuthLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthenticatedAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthenticatedAppsIndexRoute
|
||||||
'/audits': typeof AuthAuditsIndexRoute
|
'/blacklist': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/blacklists': typeof AuthBlacklistsIndexRoute
|
'/command': typeof AuthenticatedCommandIndexRoute
|
||||||
'/commands': typeof AuthCommandsIndexRoute
|
'/device': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/room': typeof AuthenticatedRoomIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
'/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 {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
'/(auth)/login/': typeof authLoginIndexRoute
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/_auth/agent/': typeof AuthAgentIndexRoute
|
'/_auth/login/': typeof AuthLoginIndexRoute
|
||||||
'/_auth/apps/': typeof AuthAppsIndexRoute
|
'/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
|
||||||
'/_auth/audits/': typeof AuthAuditsIndexRoute
|
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
|
||||||
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
|
'/_authenticated/blacklist/': typeof AuthenticatedBlacklistIndexRoute
|
||||||
'/_auth/commands/': typeof AuthCommandsIndexRoute
|
'/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
|
||||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
'/_authenticated/device/': typeof AuthenticatedDeviceIndexRoute
|
||||||
'/_auth/device/': typeof AuthDeviceIndexRoute
|
'/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
|
||||||
'/_auth/remote-control/': typeof AuthRemoteControlIndexRoute
|
'/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
'/_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 {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -262,93 +122,52 @@ export interface FileRouteTypes {
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/audits'
|
| '/blacklist'
|
||||||
| '/blacklists'
|
| '/command'
|
||||||
| '/commands'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/remote-control'
|
| '/room'
|
||||||
| '/role'
|
| '/room/$roomName'
|
||||||
| '/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
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/audits'
|
| '/blacklist'
|
||||||
| '/blacklists'
|
| '/command'
|
||||||
| '/commands'
|
|
||||||
| '/dashboard'
|
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/remote-control'
|
| '/room'
|
||||||
| '/role'
|
| '/room/$roomName'
|
||||||
| '/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:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
| '/(auth)/login/'
|
| '/_authenticated'
|
||||||
| '/_auth/agent/'
|
| '/_auth/login/'
|
||||||
| '/_auth/apps/'
|
| '/_authenticated/agent/'
|
||||||
| '/_auth/audits/'
|
| '/_authenticated/apps/'
|
||||||
| '/_auth/blacklists/'
|
| '/_authenticated/blacklist/'
|
||||||
| '/_auth/commands/'
|
| '/_authenticated/command/'
|
||||||
| '/_auth/dashboard/'
|
| '/_authenticated/device/'
|
||||||
| '/_auth/device/'
|
| '/_authenticated/room/'
|
||||||
| '/_auth/remote-control/'
|
| '/_authenticated/room/$roomName/'
|
||||||
| '/_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
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||||
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/_authenticated': {
|
||||||
|
id: '/_authenticated'
|
||||||
|
path: ''
|
||||||
|
fullPath: ''
|
||||||
|
preLoaderRoute: typeof AuthenticatedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_auth': {
|
'/_auth': {
|
||||||
id: '/_auth'
|
id: '/_auth'
|
||||||
path: ''
|
path: ''
|
||||||
|
|
@ -363,236 +182,103 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_auth/user/': {
|
'/_authenticated/room/': {
|
||||||
id: '/_auth/user/'
|
id: '/_authenticated/room/'
|
||||||
path: '/user'
|
path: '/room'
|
||||||
fullPath: '/user'
|
fullPath: '/room'
|
||||||
preLoaderRoute: typeof AuthUserIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/rooms/': {
|
'/_authenticated/device/': {
|
||||||
id: '/_auth/rooms/'
|
id: '/_authenticated/device/'
|
||||||
path: '/rooms'
|
|
||||||
fullPath: '/rooms'
|
|
||||||
preLoaderRoute: typeof AuthRoomsIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/role/': {
|
|
||||||
id: '/_auth/role/'
|
|
||||||
path: '/role'
|
|
||||||
fullPath: '/role'
|
|
||||||
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'
|
path: '/device'
|
||||||
fullPath: '/device'
|
fullPath: '/device'
|
||||||
preLoaderRoute: typeof AuthDeviceIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedDeviceIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/dashboard/': {
|
'/_authenticated/command/': {
|
||||||
id: '/_auth/dashboard/'
|
id: '/_authenticated/command/'
|
||||||
path: '/dashboard'
|
path: '/command'
|
||||||
fullPath: '/dashboard'
|
fullPath: '/command'
|
||||||
preLoaderRoute: typeof AuthDashboardIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/commands/': {
|
'/_authenticated/blacklist/': {
|
||||||
id: '/_auth/commands/'
|
id: '/_authenticated/blacklist/'
|
||||||
path: '/commands'
|
path: '/blacklist'
|
||||||
fullPath: '/commands'
|
fullPath: '/blacklist'
|
||||||
preLoaderRoute: typeof AuthCommandsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedBlacklistIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/blacklists/': {
|
'/_authenticated/apps/': {
|
||||||
id: '/_auth/blacklists/'
|
id: '/_authenticated/apps/'
|
||||||
path: '/blacklists'
|
|
||||||
fullPath: '/blacklists'
|
|
||||||
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'
|
path: '/apps'
|
||||||
fullPath: '/apps'
|
fullPath: '/apps'
|
||||||
preLoaderRoute: typeof AuthAppsIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/_auth/agent/': {
|
'/_authenticated/agent/': {
|
||||||
id: '/_auth/agent/'
|
id: '/_authenticated/agent/'
|
||||||
path: '/agent'
|
path: '/agent'
|
||||||
fullPath: '/agent'
|
fullPath: '/agent'
|
||||||
preLoaderRoute: typeof AuthAgentIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedAgentIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
'/(auth)/login/': {
|
'/_auth/login/': {
|
||||||
id: '/(auth)/login/'
|
id: '/_auth/login/'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
fullPath: '/login'
|
fullPath: '/login'
|
||||||
preLoaderRoute: typeof authLoginIndexRouteImport
|
preLoaderRoute: typeof AuthLoginIndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/_auth/user/create/': {
|
|
||||||
id: '/_auth/user/create/'
|
|
||||||
path: '/user/create'
|
|
||||||
fullPath: '/user/create'
|
|
||||||
preLoaderRoute: typeof AuthUserCreateIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/rooms/$roomName/': {
|
'/_authenticated/room/$roomName/': {
|
||||||
id: '/_auth/rooms/$roomName/'
|
id: '/_authenticated/room/$roomName/'
|
||||||
path: '/rooms/$roomName'
|
path: '/room/$roomName'
|
||||||
fullPath: '/rooms/$roomName'
|
fullPath: '/room/$roomName'
|
||||||
preLoaderRoute: typeof AuthRoomsRoomNameIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedRoomRoomNameIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
|
||||||
'/_auth/role/create/': {
|
|
||||||
id: '/_auth/role/create/'
|
|
||||||
path: '/role/create'
|
|
||||||
fullPath: '/role/create'
|
|
||||||
preLoaderRoute: typeof AuthRoleCreateIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/profile/change-password/': {
|
|
||||||
id: '/_auth/profile/change-password/'
|
|
||||||
path: '/profile/change-password'
|
|
||||||
fullPath: '/profile/change-password'
|
|
||||||
preLoaderRoute: typeof AuthProfileChangePasswordIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/profile/$userName/': {
|
|
||||||
id: '/_auth/profile/$userName/'
|
|
||||||
path: '/profile/$userName'
|
|
||||||
fullPath: '/profile/$userName'
|
|
||||||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/(auth)/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'
|
|
||||||
fullPath: '/user/role/$roleId'
|
|
||||||
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'
|
|
||||||
fullPath: '/user/change-password/$userName'
|
|
||||||
preLoaderRoute: typeof AuthUserChangePasswordUserNameIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/rooms/$roomName/folder-status/': {
|
|
||||||
id: '/_auth/rooms/$roomName/folder-status/'
|
|
||||||
path: '/rooms/$roomName/folder-status'
|
|
||||||
fullPath: '/rooms/$roomName/folder-status'
|
|
||||||
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'
|
|
||||||
fullPath: '/role/$id/edit'
|
|
||||||
preLoaderRoute: typeof AuthRoleIdEditIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
|
AuthLoginIndexRoute: typeof AuthLoginIndexRoute
|
||||||
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
|
|
||||||
AuthProfileUserNameIndexRoute: typeof AuthProfileUserNameIndexRoute
|
|
||||||
AuthProfileChangePasswordIndexRoute: typeof AuthProfileChangePasswordIndexRoute
|
|
||||||
AuthRoleCreateIndexRoute: typeof AuthRoleCreateIndexRoute
|
|
||||||
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 = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthAgentIndexRoute: AuthAgentIndexRoute,
|
AuthLoginIndexRoute: AuthLoginIndexRoute,
|
||||||
AuthAppsIndexRoute: AuthAppsIndexRoute,
|
|
||||||
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
|
|
||||||
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
|
|
||||||
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
|
|
||||||
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
|
||||||
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
|
||||||
AuthRemoteControlIndexRoute: AuthRemoteControlIndexRoute,
|
|
||||||
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
|
||||||
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
|
||||||
AuthUserIndexRoute: AuthUserIndexRoute,
|
|
||||||
AuthProfileUserNameIndexRoute: AuthProfileUserNameIndexRoute,
|
|
||||||
AuthProfileChangePasswordIndexRoute: AuthProfileChangePasswordIndexRoute,
|
|
||||||
AuthRoleCreateIndexRoute: AuthRoleCreateIndexRoute,
|
|
||||||
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
|
|
||||||
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
|
||||||
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
|
||||||
AuthRoomsRoomNameConnectIndexRoute: AuthRoomsRoomNameConnectIndexRoute,
|
|
||||||
AuthRoomsRoomNameFolderStatusIndexRoute:
|
|
||||||
AuthRoomsRoomNameFolderStatusIndexRoute,
|
|
||||||
AuthUserChangePasswordUserNameIndexRoute:
|
|
||||||
AuthUserChangePasswordUserNameIndexRoute,
|
|
||||||
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
|
|
||||||
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|
||||||
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
|
||||||
|
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
|
||||||
|
AuthenticatedBlacklistIndexRoute: typeof AuthenticatedBlacklistIndexRoute
|
||||||
|
AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
|
||||||
|
AuthenticatedDeviceIndexRoute: typeof AuthenticatedDeviceIndexRoute
|
||||||
|
AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
|
||||||
|
AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
|
||||||
|
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
|
||||||
|
AuthenticatedBlacklistIndexRoute: AuthenticatedBlacklistIndexRoute,
|
||||||
|
AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
|
||||||
|
AuthenticatedDeviceIndexRoute: AuthenticatedDeviceIndexRoute,
|
||||||
|
AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
|
||||||
|
AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||||
|
AuthenticatedRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
authLoginIndexRoute: authLoginIndexRoute,
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||||
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
||||||
import { LoginForm } from '@/components/forms/login-form'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/(auth)/login/')({
|
|
||||||
beforeLoad: async ({ context }) => {
|
|
||||||
const { token } = context.auth
|
|
||||||
if (token) throw redirect({ to: '/' })
|
|
||||||
},
|
|
||||||
component: LoginPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
function LoginPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-background to-muted/20">
|
|
||||||
<LoginForm className="w-full max-w-md" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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 mã đă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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +1,22 @@
|
||||||
import ErrorRoute from "@/components/pages/error-route";
|
import { Outlet, createRootRouteWithContext, HeadContent } from '@tanstack/react-router'
|
||||||
import NotFound from "@/components/pages/not-found";
|
import type { AuthTokenProps } from '@/hooks/useAuthtoken'
|
||||||
import { type IAuthContext } from "@/types/auth";
|
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
createRootRouteWithContext,
|
|
||||||
HeadContent,
|
|
||||||
Outlet,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface RouterContext {
|
||||||
title: string;
|
auth: AuthTokenProps
|
||||||
path: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MyRouterContext {
|
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||||
auth: IAuthContext;
|
head: () => ({
|
||||||
queryClient: QueryClient;
|
meta: [
|
||||||
breadcrumbs?: BreadcrumbItem[];
|
{ title: "Quản lý phòng máy" },
|
||||||
}
|
{ name: "description", content: "Ứng dụng quản lý thiết bị và phần mềm" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<HeadContent />
|
||||||
|
<Outlet />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
|
||||||
component: () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HeadContent />
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
notFoundComponent: () => {
|
|
||||||
return <NotFound />;
|
|
||||||
},
|
|
||||||
errorComponent: ({ error }) => {
|
|
||||||
return <ErrorRoute error={error.message} />;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +1,16 @@
|
||||||
import { useEffect } from "react";
|
import {createFileRoute, Outlet, redirect} from '@tanstack/react-router'
|
||||||
import AppBreadCrumb from "@/components/app-breadcrumb";
|
|
||||||
import { AppSidebar } from "@/components/sidebars/app-sidebar";
|
|
||||||
import AvatarDropdown from "@/components/avatar-dropdown";
|
|
||||||
import SessionTimeOutErrorPage from "@/components/pages/session-timeout-error";
|
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { createFileRoute, Outlet, redirect, useRouter } from "@tanstack/react-router";
|
|
||||||
import { useUIStore } from "@/stores/uiStore";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth")({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: async ({context}) => {
|
||||||
if (!context.auth.isAuthenticated) {
|
const {authToken} = context.auth
|
||||||
throw redirect({
|
if (authToken) {
|
||||||
to: "/login",
|
throw redirect({to: '/'})
|
||||||
search: {
|
|
||||||
redirect: location.href
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: RouteComponent
|
component:AuthLayout ,
|
||||||
});
|
})
|
||||||
|
function AuthLayout() {
|
||||||
function RouteComponent() {
|
|
||||||
const auth = useAuth();
|
|
||||||
const setCurrent = useUIStore((state) => state.setCurrent);
|
|
||||||
const router = useRouter();
|
|
||||||
const navigate = Route.useNavigate();
|
|
||||||
const currentPath = router.state.location.pathname;
|
|
||||||
|
|
||||||
// Update current path in UI store when location changes
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrent(currentPath);
|
|
||||||
}, [currentPath, setCurrent]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
if (window.confirm("Bạn chắc chắn muốn đăng xuất khỏi hệ thống?")) {
|
|
||||||
auth.logout().then(() => {
|
|
||||||
router.invalidate().finally(() => {
|
|
||||||
navigate({ to: "/" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProfile = () => {
|
|
||||||
navigate({ to: "/profile/$userName", params: { userName: auth.username } } as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePassword = () => {
|
|
||||||
navigate({ to: "/profile/change-password" } as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const username = auth.username;
|
|
||||||
|
|
||||||
if (!auth.isAuthenticated) {
|
|
||||||
return <SessionTimeOutErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider className="h-screen w-screen">
|
<Outlet />
|
||||||
<AppSidebar />
|
)
|
||||||
<SidebarInset>
|
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
||||||
<AppBreadCrumb />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 ml-auto">
|
|
||||||
<AvatarDropdown
|
|
||||||
username={username}
|
|
||||||
role={auth.role}
|
|
||||||
onLogOut={handleLogout}
|
|
||||||
onProfile={handleProfile}
|
|
||||||
onChangePassword={handleChangePassword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 overflow-x-auto">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { AppManagerTemplate } from "@/template/app-manager-template";
|
|
||||||
import {
|
|
||||||
useGetAgentVersion,
|
|
||||||
useGetRoomList,
|
|
||||||
useUploadSoftware,
|
|
||||||
useUpdateAgent,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
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,
|
|
||||||
errorComponent: ErrorFetchingPage,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý Agent", path: "/_auth/agent/" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function AgentsPage() {
|
|
||||||
// Lấy danh sách version
|
|
||||||
const { data, isLoading } = useGetAgentVersion();
|
|
||||||
|
|
||||||
// Lấy danh sách phòng
|
|
||||||
const { data: roomData } = useGetRoomList();
|
|
||||||
|
|
||||||
const versionList: Version[] = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: data
|
|
||||||
? [data]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
const updateMutation = useUpdateAgent();
|
|
||||||
|
|
||||||
const handleUpload = async (
|
|
||||||
fd: FormData,
|
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void },
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await uploadMutation.mutateAsync({
|
|
||||||
formData: fd,
|
|
||||||
onUploadProgress: config?.onUploadProgress,
|
|
||||||
});
|
|
||||||
toast.success("Upload thành công!");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Upload error:", error);
|
|
||||||
toast.error("Upload thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdate = async (roomNames: string[]) => {
|
|
||||||
try {
|
|
||||||
for (const roomName of roomNames) {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success("Đã gửi yêu cầu update cho các phòng đã chọn!");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Có lỗi xảy ra khi cập nhật!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cột bảng
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppManagerTemplate<Version>
|
|
||||||
title="Quản lý Agent"
|
|
||||||
description="Quản lý và theo dõi các phiên bản Agent"
|
|
||||||
data={versionList}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={agentColumns}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
onUpdate={handleUpdate}
|
|
||||||
updateLoading={updateMutation.isPending}
|
|
||||||
rooms={roomData}
|
|
||||||
enablePagination
|
|
||||||
defaultPageSize={10}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { AppManagerTemplate } from "@/template/app-manager-template";
|
|
||||||
import {
|
|
||||||
useGetSoftwareList,
|
|
||||||
useGetRoomList,
|
|
||||||
useUploadSoftware,
|
|
||||||
useDeleteFile,
|
|
||||||
useAddRequiredFile,
|
|
||||||
useDeleteRequiredFile,
|
|
||||||
useInstallMsi,
|
|
||||||
useDownloadFiles,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { AxiosProgressEvent } from "axios";
|
|
||||||
import type { Version } from "@/types/file";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { createAppsColumns } from "@/components/columns/apps-column";
|
|
||||||
export const Route = createFileRoute("/_auth/apps/")({
|
|
||||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
|
||||||
component: AppsComponent,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý phần mềm", path: "/_auth/apps/" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function AppsComponent() {
|
|
||||||
const { data, isLoading } = useGetSoftwareList();
|
|
||||||
const { data: roomData } = useGetRoomList();
|
|
||||||
|
|
||||||
const versionList: Version[] = Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: data
|
|
||||||
? [data]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const [table, setTable] = useState<any>();
|
|
||||||
|
|
||||||
const uploadMutation = useUploadSoftware();
|
|
||||||
|
|
||||||
const installMutation = useInstallMsi();
|
|
||||||
|
|
||||||
const downloadMutation = useDownloadFiles();
|
|
||||||
|
|
||||||
const deleteMutation = useDeleteFile();
|
|
||||||
|
|
||||||
const addRequiredFileMutation = useAddRequiredFile();
|
|
||||||
|
|
||||||
const deleteRequiredFileMutation = useDeleteRequiredFile();
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => createAppsColumns(installMutation.isPending),
|
|
||||||
[installMutation.isPending]
|
|
||||||
);
|
|
||||||
// Upload file MSI
|
|
||||||
const handleUpload = async (
|
|
||||||
fd: FormData,
|
|
||||||
config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
await uploadMutation.mutateAsync({
|
|
||||||
formData: fd,
|
|
||||||
onUploadProgress: config?.onUploadProgress,
|
|
||||||
});
|
|
||||||
toast.success("Upload thành công!");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Upload error:", error);
|
|
||||||
toast.error("Upload thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Callback khi chọn phòng
|
|
||||||
const handleInstall = async (roomNames: string[]) => {
|
|
||||||
if (!table) {
|
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const roomName of roomNames) {
|
|
||||||
await installMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
data: { MsiFileIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success("Đã gửi yêu cầu cài đặt phần mềm cho các phòng đã chọn!");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Có lỗi xảy ra khi cài đặt!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDonwload = async (roomNames: string[]) => {
|
|
||||||
if (!table) {
|
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
toast.error("Vui lòng chọn ít nhất một file để tải!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const roomName of roomNames) {
|
|
||||||
await downloadMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
data: { MsiFileIds },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success("Đã gửi yêu cầu tải file cho các phòng đã chọn!");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Có lỗi xảy ra khi tải!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!table) {
|
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
toast.error("Vui lòng chọn ít nhất một file để xóa!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
|
||||||
toast.success("Xóa phần mềm thành công!");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Xóa phần mềm thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromRequiredList = async () => {
|
|
||||||
if (!table) return;
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
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 });
|
|
||||||
toast.success("Xóa file khỏi danh sách thành công!");
|
|
||||||
if (table) {
|
|
||||||
table.setRowSelection({});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Delete from required list error:", e);
|
|
||||||
toast.error("Có lỗi xảy ra khi xóa!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFromServer = async () => {
|
|
||||||
if (!table) return;
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
|
||||||
toast.success("Xóa phần mềm từ server thành công!");
|
|
||||||
if (table) {
|
|
||||||
table.setRowSelection({});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Delete error:", e);
|
|
||||||
toast.error("Có lỗi xảy ra khi xóa!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToRequired = async () => {
|
|
||||||
if (!table) {
|
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
toast.error("Vui lòng chọn ít nhất một file!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const row of selectedRows) {
|
|
||||||
const { fileName, version } = row.original;
|
|
||||||
await addRequiredFileMutation.mutateAsync({
|
|
||||||
fileName,
|
|
||||||
version,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success("Thêm file vào danh sách thành công!");
|
|
||||||
table.setRowSelection({});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Add required file error:", e);
|
|
||||||
toast.error("Có lỗi xảy ra!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AppManagerTemplate<Version>
|
|
||||||
title="Quản lý phần mềm"
|
|
||||||
uploadFormTitle="Tải lên || Cập nhật file phần mềm"
|
|
||||||
description="Quản lý và gửi yêu cầu cài đặt phần mềm hoặc file cấu hình"
|
|
||||||
data={versionList}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={columns}
|
|
||||||
onUpload={handleUpload}
|
|
||||||
onUpdate={handleInstall}
|
|
||||||
onDownload={handleDonwload}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onDeleteFromServer={handleDeleteFromServer}
|
|
||||||
onDeleteFromRequired={handleDeleteFromRequiredList}
|
|
||||||
onAddToRequired={handleAddToRequired}
|
|
||||||
updateLoading={installMutation.isPending}
|
|
||||||
downloadLoading={downloadMutation.isPending}
|
|
||||||
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
|
|
||||||
addToRequiredLoading={addRequiredFileMutation.isPending}
|
|
||||||
onTableInit={setTable}
|
|
||||||
rooms={roomData}
|
|
||||||
enablePagination
|
|
||||||
defaultPageSize={10}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import {
|
|
||||||
useGetBlacklist,
|
|
||||||
useGetRoomList,
|
|
||||||
useAddBlacklist,
|
|
||||||
useDeleteBlacklist,
|
|
||||||
useUpdateDeviceBlacklist,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import type { Blacklist } from "@/types/black-list";
|
|
||||||
import { BlackListManagerTemplate } from "@/template/table-manager-template";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/blacklists/")({
|
|
||||||
head: () => ({ meta: [{ title: "Danh sách các ứng dụng bị chặn" }] }),
|
|
||||||
component: BlacklistComponent,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý danh sách chặn", path: "/_auth/blacklists/" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function BlacklistComponent() {
|
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
// Lấy danh sách blacklist
|
|
||||||
const { data, isLoading } = useGetBlacklist();
|
|
||||||
|
|
||||||
// Lấy danh sách phòng
|
|
||||||
const { data: roomData = [] } = useGetRoomList();
|
|
||||||
|
|
||||||
const blacklist: Blacklist[] = Array.isArray(data)
|
|
||||||
? (data as Blacklist[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const columns: ColumnDef<Blacklist>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "id",
|
|
||||||
header: "STT",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "appName",
|
|
||||||
header: "Tên ứng dụng",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "processName",
|
|
||||||
header: "Tên tiến trình",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: "Ngày tạo",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "updatedAt",
|
|
||||||
header: "Ngày cập nhật",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdBy",
|
|
||||||
header: "Người tạo",
|
|
||||||
cell: (info) => info.getValue(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: () => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked && data) {
|
|
||||||
const allIds = data.map((item: { id: number }) => item.id);
|
|
||||||
setSelectedRows(new Set(allIds));
|
|
||||||
} else {
|
|
||||||
setSelectedRows(new Set());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedRows.has(row.original.id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newSelected = new Set(selectedRows);
|
|
||||||
if (e.target.checked) {
|
|
||||||
newSelected.add(row.original.id);
|
|
||||||
} else {
|
|
||||||
newSelected.delete(row.original.id);
|
|
||||||
}
|
|
||||||
setSelectedRows(newSelected);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// API mutations
|
|
||||||
const addNewBlacklistMutation = useAddBlacklist();
|
|
||||||
const deleteBlacklistMutation = useDeleteBlacklist();
|
|
||||||
const updateDeviceMutation = useUpdateDeviceBlacklist();
|
|
||||||
|
|
||||||
// Thêm blacklist
|
|
||||||
const handleAddNewBlacklist = async (blacklistData: {
|
|
||||||
appName: string;
|
|
||||||
processName: string;
|
|
||||||
}) => {
|
|
||||||
try {
|
|
||||||
await addNewBlacklistMutation.mutateAsync(blacklistData);
|
|
||||||
toast.success("Thêm mới thành công!");
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Add blacklist error:", error);
|
|
||||||
toast.error("Thêm mới thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Xoá blacklist
|
|
||||||
const handleDeleteBlacklist = async () => {
|
|
||||||
try {
|
|
||||||
for (const blacklistId of selectedRows) {
|
|
||||||
await deleteBlacklistMutation.mutateAsync(blacklistId);
|
|
||||||
}
|
|
||||||
toast.success("Xóa thành công!");
|
|
||||||
setSelectedRows(new Set());
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error("Delete blacklist error:", error);
|
|
||||||
toast.error("Xóa thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateDevice = async (target: string | string[]) => {
|
|
||||||
const targets = Array.isArray(target) ? target : [target];
|
|
||||||
try {
|
|
||||||
for (const roomName of targets) {
|
|
||||||
await updateDeviceMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
toast.success(`Đã gửi cập nhật cho ${roomName}`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("Update device error:", e);
|
|
||||||
toast.error("Có lỗi xảy ra khi cập nhật!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BlackListManagerTemplate<Blacklist>
|
|
||||||
title="Danh sách các ứng dụng bị chặn"
|
|
||||||
description="Quản lý các ứng dụng và tiến trình bị chặn trên thiết bị"
|
|
||||||
data={blacklist}
|
|
||||||
columns={columns}
|
|
||||||
isLoading={isLoading}
|
|
||||||
rooms={roomData}
|
|
||||||
onAdd={handleAddNewBlacklist}
|
|
||||||
onDelete={handleDeleteBlacklist}
|
|
||||||
onUpdate={handleUpdateDevice}
|
|
||||||
enablePagination
|
|
||||||
defaultPageSize={10}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
|
||||||
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
|
||||||
import {
|
|
||||||
useGetCommandList,
|
|
||||||
useGetRoomList,
|
|
||||||
useAddCommand,
|
|
||||||
useUpdateCommand,
|
|
||||||
useDeleteCommand,
|
|
||||||
useSendCommand,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import type { ShellCommandData } from "@/components/forms/command-form";
|
|
||||||
import type { CommandRegistry } from "@/types/command-registry";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/commands/")({
|
|
||||||
head: () => ({ meta: [{ title: "Gửi lệnh từ xa" }] }),
|
|
||||||
component: CommandPage,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
// Read active tab from URL search params (client-side) to reflect breadcrumb
|
|
||||||
let activeTab = "list";
|
|
||||||
try {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
activeTab = params.get("tab") || "list";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
activeTab = "list";
|
|
||||||
}
|
|
||||||
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý lệnh", path: "/_auth/commands/" },
|
|
||||||
{
|
|
||||||
title: activeTab === "execute" ? "Lệnh thủ công" : "Danh sách",
|
|
||||||
path: `/ _auth/commands/?tab=${activeTab}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function CommandPage() {
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
const [selectedCommand, setSelectedCommand] = useState<CommandRegistry | null>(null);
|
|
||||||
const [table, setTable] = useState<any>();
|
|
||||||
|
|
||||||
// Fetch commands
|
|
||||||
const { data: commands = [], isLoading } = useGetCommandList();
|
|
||||||
|
|
||||||
// Fetch rooms
|
|
||||||
const { data: roomData = [] } = useGetRoomList();
|
|
||||||
|
|
||||||
const commandList: CommandRegistry[] = Array.isArray(commands)
|
|
||||||
? commands.map((cmd: any) => ({
|
|
||||||
...cmd,
|
|
||||||
qoS: cmd.qoS ?? 0,
|
|
||||||
isRetained: cmd.isRetained ?? false,
|
|
||||||
}))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const addCommandMutation = useAddCommand();
|
|
||||||
const updateCommandMutation = useUpdateCommand();
|
|
||||||
const deleteCommandMutation = useDeleteCommand();
|
|
||||||
const sendCommandMutation = useSendCommand();
|
|
||||||
|
|
||||||
// Columns for command table
|
|
||||||
const columns: ColumnDef<CommandRegistry>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "commandName",
|
|
||||||
header: () => <div className="min-w-[220px] whitespace-normal">Tên lệnh</div>,
|
|
||||||
size: 100,
|
|
||||||
cell: ({ getValue, row }) => {
|
|
||||||
const full = (getValue() as string) || row.original.commandName || "";
|
|
||||||
return (
|
|
||||||
<div className="min-w-[220px] whitespace-normal break-words">
|
|
||||||
<span className="font-semibold block leading-tight">{full}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "commandType",
|
|
||||||
header: "Loại lệnh",
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const type = getValue() as number;
|
|
||||||
const typeMap: Record<number, string> = {
|
|
||||||
1: "RESTART",
|
|
||||||
2: "SHUTDOWN",
|
|
||||||
3: "TASKKILL",
|
|
||||||
4: "BLOCK",
|
|
||||||
};
|
|
||||||
return <span>{typeMap[type] || "UNKNOWN"}</span>;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "commandContent",
|
|
||||||
header: "Nội dung lệnh",
|
|
||||||
size: 130,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<div className="max-w-[130px]">
|
|
||||||
<code className="text-xs bg-muted/50 px-1.5 py-0.5 rounded truncate block">
|
|
||||||
{(getValue() as string).substring(0, 40)}...
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "qoS",
|
|
||||||
header: "QoS",
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const qos = getValue() as number | undefined;
|
|
||||||
const qosValue = qos !== undefined ? qos : 0;
|
|
||||||
const colors = {
|
|
||||||
0: "text-blue-600",
|
|
||||||
1: "text-amber-600",
|
|
||||||
2: "text-red-600",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={colors[qosValue as 0 | 1 | 2]}>{qosValue}</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "isRetained",
|
|
||||||
header: "Lưu trữ",
|
|
||||||
cell: ({ getValue }) => {
|
|
||||||
const retained = getValue() as boolean;
|
|
||||||
return retained ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-sm text-green-600">Có</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<X className="h-4 w-4 text-gray-400" />
|
|
||||||
<span className="text-sm text-gray-400">Không</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "select",
|
|
||||||
header: () => <div className="text-center text-xs">Thực thi</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={row.getIsSelected?.() ?? false}
|
|
||||||
onChange={row.getToggleSelectedHandler?.()}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <div className="text-center text-xs">Hành động</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex gap-2 justify-center">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedCommand(row.original);
|
|
||||||
setIsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteCommand(row.original.id);
|
|
||||||
}}
|
|
||||||
disabled={deleteCommandMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
enableSorting: false,
|
|
||||||
enableHiding: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Handle form submit
|
|
||||||
const handleFormSubmit = async (data: CommandRegistryFormData) => {
|
|
||||||
try {
|
|
||||||
if (selectedCommand) {
|
|
||||||
// Update
|
|
||||||
await updateCommandMutation.mutateAsync({
|
|
||||||
commandId: selectedCommand.id,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Add
|
|
||||||
await addCommandMutation.mutateAsync(data);
|
|
||||||
}
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
setSelectedCommand(null);
|
|
||||||
toast.success(selectedCommand ? "Cập nhật lệnh thành công!" : "Thêm lệnh thành công!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Form submission error:", error);
|
|
||||||
toast.error(selectedCommand ? "Cập nhật lệnh thất bại!" : "Thêm lệnh thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle delete
|
|
||||||
const handleDeleteCommand = async (commandId: number) => {
|
|
||||||
if (!confirm("Bạn có chắc muốn xóa lệnh này?")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteCommandMutation.mutateAsync(commandId);
|
|
||||||
toast.success("Xóa lệnh thành công!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Delete error:", error);
|
|
||||||
toast.error("Xóa lệnh thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle execute commands from list
|
|
||||||
const handleExecuteSelected = async (targets: string[]) => {
|
|
||||||
if (!table) {
|
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
|
||||||
if (selectedRows.length === 0) {
|
|
||||||
toast.error("Vui lòng chọn ít nhất một lệnh để thực thi!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const target of targets) {
|
|
||||||
for (const row of selectedRows) {
|
|
||||||
// API expects PascalCase directly
|
|
||||||
const apiData = {
|
|
||||||
Command: row.original.commandContent,
|
|
||||||
QoS: row.original.qoS,
|
|
||||||
IsRetained: row.original.isRetained,
|
|
||||||
};
|
|
||||||
|
|
||||||
await sendCommandMutation.mutateAsync({
|
|
||||||
roomName: target,
|
|
||||||
data: apiData as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success("Đã gửi yêu cầu thực thi lệnh cho các mục đã chọn!");
|
|
||||||
if (table) {
|
|
||||||
table.setRowSelection({});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Có lỗi xảy ra khi thực thi!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle execute custom command
|
|
||||||
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
|
|
||||||
try {
|
|
||||||
for (const target of targets) {
|
|
||||||
// API expects PascalCase directly
|
|
||||||
const apiData = {
|
|
||||||
Command: commandData.command,
|
|
||||||
QoS: commandData.qos,
|
|
||||||
IsRetained: commandData.isRetained,
|
|
||||||
};
|
|
||||||
await sendCommandMutation.mutateAsync({
|
|
||||||
roomName: target,
|
|
||||||
data: apiData as any,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
toast.success("Đã gửi lệnh tùy chỉnh cho các mục đã chọn!");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Gửi lệnh tùy chỉnh thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CommandSubmitTemplate
|
|
||||||
title="Gửi lệnh từ xa"
|
|
||||||
description="Quản lý và gửi yêu cầu thực thi các lệnh trên thiết bị"
|
|
||||||
data={commandList}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={columns}
|
|
||||||
dialogOpen={isDialogOpen}
|
|
||||||
onDialogOpen={setIsDialogOpen}
|
|
||||||
dialogTitle={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
|
||||||
onAddNew={() => {
|
|
||||||
setSelectedCommand(null);
|
|
||||||
setIsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onTableInit={setTable}
|
|
||||||
formContent={
|
|
||||||
<CommandRegistryForm
|
|
||||||
onSubmit={handleFormSubmit}
|
|
||||||
closeDialog={() => setIsDialogOpen(false)}
|
|
||||||
initialData={selectedCommand || undefined}
|
|
||||||
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onExecuteSelected={handleExecuteSelected}
|
|
||||||
onExecuteCustom={handleExecuteCustom}
|
|
||||||
isExecuting={sendCommandMutation.isPending}
|
|
||||||
rooms={roomData}
|
|
||||||
scrollable={true}
|
|
||||||
maxHeight="500px"
|
|
||||||
enablePagination={false}
|
|
||||||
defaultPageSize={10}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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: "#" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
125
src/routes/_auth/login/index.tsx
Normal file
125
src/routes/_auth/login/index.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
formOptions,
|
||||||
|
useForm,
|
||||||
|
} from '@tanstack/react-form'
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultInput: LoginFormProps = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formOpts = formOptions({
|
||||||
|
defaultValues: defaultInput,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/login/')({
|
||||||
|
beforeLoad: async ({ context }) => {
|
||||||
|
const { authToken } = context.auth
|
||||||
|
if (authToken) throw redirect({ to: '/' })
|
||||||
|
},
|
||||||
|
component: LoginForm,
|
||||||
|
})
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
|
const form = useForm({
|
||||||
|
...formOpts,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
console.log('Submitting login form with values:', value)
|
||||||
|
|
||||||
|
// Giả lập đăng nhập
|
||||||
|
if (value.username === 'admin' && value.password === '123456') {
|
||||||
|
alert('Đăng nhập thành công!')
|
||||||
|
// Thêm xử lý lưu token, redirect...
|
||||||
|
} else {
|
||||||
|
alert('Tài khoản hoặc mật khẩu không đúng.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md mx-auto mt-20 p-6">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Đăng nhập</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Vui lòng nhập thông tin đăng nhập của bạn.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
form.handleSubmit()
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{/* Username */}
|
||||||
|
<form.Field name="username">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="username">Tên đăng nhập</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Tên đăng nhập"
|
||||||
|
/>
|
||||||
|
{field.state.meta.isTouched && field.state.meta.errors && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">
|
||||||
|
{field.state.meta.errors}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<form.Field name="password">
|
||||||
|
{(field) => (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Mật khẩu</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={field.state.value}
|
||||||
|
onChange={(e) => field.handleChange(e.target.value)}
|
||||||
|
placeholder="Mật khẩu"
|
||||||
|
/>
|
||||||
|
{field.state.meta.isTouched && field.state.meta.errors && (
|
||||||
|
<p className="text-sm text-red-500 mt-1">
|
||||||
|
{field.state.meta.errors}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Đăng nhập
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Chưa có tài khoản? <span className="underline cursor-pointer">Đăng ký</span>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { User, Key, Shield } from "lucide-react";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/profile/$userName/")({
|
|
||||||
component: UserProfileComponent,
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
const { userName } = params as unknown as { userName: string };
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Tài khoản", path: "#" },
|
|
||||||
{ title: "Thông tin cá nhân", path: `/profile/${userName}` },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function UserProfileComponent() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const auth = useAuth();
|
|
||||||
const { userName } = Route.useParams() as { userName: string };
|
|
||||||
|
|
||||||
// Only allow viewing own profile
|
|
||||||
const isOwnProfile = auth.username === userName;
|
|
||||||
|
|
||||||
if (!isOwnProfile) {
|
|
||||||
return (
|
|
||||||
<div className="container w-2/3 mx-auto">
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
||||||
<p className="text-muted-foreground">Bạn không có quyền xem hồ sơ này</p>
|
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/dashboard" })}>
|
|
||||||
Quay lại
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container w-2/3 mx-auto">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{/* Avatar Section */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<div className="w-24 h-24 rounded-full bg-muted flex items-center justify-center border">
|
|
||||||
<User className="h-12 w-12 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Section */}
|
|
||||||
<div className="space-y-0">
|
|
||||||
<div className="flex justify-between items-center py-4 border-b">
|
|
||||||
<span className="text-muted-foreground">Tên đăng nhập</span>
|
|
||||||
<span className="font-medium">{auth.username}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center py-4 border-b">
|
|
||||||
<span className="text-muted-foreground">Họ và tên</span>
|
|
||||||
<span className="font-medium">{auth.name || "Chưa cập nhật"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center py-4 border-b">
|
|
||||||
<span className="text-muted-foreground">Vai trò</span>
|
|
||||||
<Badge variant="outline" className="flex items-center gap-1">
|
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
{auth.role.roleName || "Chưa cập nhật"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center py-4 border-b">
|
|
||||||
<span className="text-muted-foreground">Cấp độ ưu tiên</span>
|
|
||||||
<span className="font-medium">{auth.role.priority}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Button */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => navigate({ to: "/profile/change-password" as any })}
|
|
||||||
>
|
|
||||||
<Key className="h-4 w-4 mr-2" />
|
|
||||||
Đổi mật khẩu
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,157 +0,0 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useChangePassword } from "@/hooks/queries";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { LoaderCircle } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/profile/change-password/")({
|
|
||||||
component: SelfChangePasswordComponent,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Tài khoản", path: "#" },
|
|
||||||
{ title: "Đổi mật khẩu", path: "/profile/change-password" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function SelfChangePasswordComponent() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const mutation = useChangePassword();
|
|
||||||
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword, setNewPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
if (!currentPassword) {
|
|
||||||
setError("Mật khẩu hiện tại là bắt buộc");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!newPassword) {
|
|
||||||
setError("Mật khẩu mới là bắt buộc");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
setError("Mật khẩu phải có ít nhất 6 ký tự");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!confirmPassword) {
|
|
||||||
setError("Xác nhận mật khẩu là bắt buộc");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
setError("Mật khẩu mới và xác nhận mật khẩu chưa giống nhau");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (currentPassword === newPassword) {
|
|
||||||
setError("Mật khẩu mới không được trùng với mật khẩu hiện tại");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
mutation.mutate(
|
|
||||||
{ currentPassword: currentPassword, newPassword: newPassword },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Đổi mật khẩu thành công");
|
|
||||||
setCurrentPassword("");
|
|
||||||
setNewPassword("");
|
|
||||||
setConfirmPassword("");
|
|
||||||
mutation.reset();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Đổi mật khẩu thất bại, có lỗi xảy ra vui lòng thử lại");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
navigate({ to: "/dashboard" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container w-2/3 mx-auto">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="currentPassword">Mật khẩu hiện tại</Label>
|
|
||||||
<Input
|
|
||||||
id="currentPassword"
|
|
||||||
type="password"
|
|
||||||
placeholder="Nhập mật khẩu hiện tại"
|
|
||||||
value={currentPassword}
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="newPassword">Mật khẩu mới</Label>
|
|
||||||
<Input
|
|
||||||
id="newPassword"
|
|
||||||
type="password"
|
|
||||||
placeholder="Nhập mật khẩu mới"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword">Xác nhận mật khẩu mới</Label>
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
placeholder="Nhập lại mật khẩu mới"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Lỗi</AlertTitle>
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mutation.isError && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertTitle>Lỗi</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Có lỗi xảy ra, vui lòng thử lại
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? (
|
|
||||||
<>
|
|
||||||
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Đang lưu....
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Cập nhật"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,155 +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";
|
|
||||||
import { buildMeshProxyUrl } from "@/config/api";
|
|
||||||
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Chuyển URL MeshCentral thành proxy URL
|
|
||||||
const originalUrl = new URL(data.url);
|
|
||||||
const pathAndQuery = originalUrl.pathname + originalUrl.search;
|
|
||||||
const proxyUrlFull = buildMeshProxyUrl(pathAndQuery);
|
|
||||||
|
|
||||||
console.log("[RemoteControl] Proxy URL:", proxyUrlFull);
|
|
||||||
setProxyUrl(proxyUrlFull);
|
|
||||||
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ị và 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import {
|
|
||||||
useGetRoleById,
|
|
||||||
useGetRolePermissions,
|
|
||||||
useGetPermissionList,
|
|
||||||
useToggleRolePermission,
|
|
||||||
useAssignRolePermissions,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Shield, ArrowLeft, Save, RefreshCw } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { Permission, PermissionOnRole } from "@/types/permission";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/role/$id/edit/")({
|
|
||||||
component: EditRolePermissionsComponent,
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý role", path: "/role" },
|
|
||||||
{ title: "Chỉnh sửa quyền", path: `/role/${params.id}/edit` },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function EditRolePermissionsComponent() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { id } = Route.useParams();
|
|
||||||
const roleId = Number(id);
|
|
||||||
|
|
||||||
// Queries
|
|
||||||
const { data: role, isLoading: roleLoading } = useGetRoleById(roleId);
|
|
||||||
const { data: rolePermissions = [], isLoading: rolePermissionsLoading } = useGetRolePermissions(roleId);
|
|
||||||
const { data: allPermissions = [], isLoading: permissionsLoading } = useGetPermissionList();
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const toggleMutation = useToggleRolePermission();
|
|
||||||
const assignMutation = useAssignRolePermissions();
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
|
|
||||||
// Initialize selected permissions from role's current permissions
|
|
||||||
useEffect(() => {
|
|
||||||
if (rolePermissions && Array.isArray(rolePermissions)) {
|
|
||||||
// Use permissionEnum as the identifier (matches value from permission list)
|
|
||||||
const checkedPermissions = rolePermissions
|
|
||||||
.filter((p: PermissionOnRole) => p.isChecked === 1)
|
|
||||||
.map((p: PermissionOnRole) => p.permissionEnum);
|
|
||||||
setSelectedPermissions(checkedPermissions);
|
|
||||||
}
|
|
||||||
}, [rolePermissions]);
|
|
||||||
|
|
||||||
// Group permissions by parent (category)
|
|
||||||
const groupedPermissions = useMemo(() => {
|
|
||||||
const groups: Record<string, Permission[]> = {};
|
|
||||||
const permissionList = Array.isArray(allPermissions) ? allPermissions : [];
|
|
||||||
|
|
||||||
// First pass: identify all parent categories
|
|
||||||
const parentPermissions: Permission[] = [];
|
|
||||||
const childPermissions: Permission[] = [];
|
|
||||||
|
|
||||||
permissionList.forEach((perm: Permission) => {
|
|
||||||
const permValue = perm.value ?? perm.enum ?? 0;
|
|
||||||
const isParent = permValue % 10 === 0;
|
|
||||||
if (isParent) {
|
|
||||||
parentPermissions.push(perm);
|
|
||||||
groups[perm.name] = [];
|
|
||||||
} else {
|
|
||||||
childPermissions.push(perm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: assign children to parent categories
|
|
||||||
childPermissions.forEach((perm: Permission) => {
|
|
||||||
const permValue = perm.value ?? perm.enum ?? 0;
|
|
||||||
const parentEnum = Math.floor(permValue / 10) * 10;
|
|
||||||
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
|
|
||||||
const parentName = parent?.name || "Khác";
|
|
||||||
if (!groups[parentName]) {
|
|
||||||
groups[parentName] = [];
|
|
||||||
}
|
|
||||||
groups[parentName].push(perm);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Third pass: add parent permissions that have no children as selectable items
|
|
||||||
parentPermissions.forEach((parent) => {
|
|
||||||
const parentValue = parent.value ?? parent.enum ?? 0;
|
|
||||||
const hasChildren = childPermissions.some((child) => {
|
|
||||||
const childValue = child.value ?? child.enum ?? 0;
|
|
||||||
return Math.floor(childValue / 10) * 10 === parentValue;
|
|
||||||
});
|
|
||||||
if (!hasChildren) {
|
|
||||||
groups[parent.name].push(parent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove empty groups
|
|
||||||
Object.keys(groups).forEach((key) => {
|
|
||||||
if (groups[key].length === 0) {
|
|
||||||
delete groups[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [allPermissions]);
|
|
||||||
|
|
||||||
// Helper to get unique identifier for permission (use value as ID)
|
|
||||||
const getPermId = (perm: Permission) => perm.value ?? perm.id ?? 0;
|
|
||||||
|
|
||||||
const handleTogglePermission = (permissionValue: number) => {
|
|
||||||
setSelectedPermissions((prev) =>
|
|
||||||
prev.includes(permissionValue)
|
|
||||||
? prev.filter((v) => v !== permissionValue)
|
|
||||||
: [...prev, permissionValue]
|
|
||||||
);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = (categoryPermissions: Permission[]) => {
|
|
||||||
const allValues = categoryPermissions.map((p) => getPermId(p));
|
|
||||||
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
setSelectedPermissions((prev) =>
|
|
||||||
prev.filter((v) => !allValues.includes(v))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
|
|
||||||
}
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save all permissions at once
|
|
||||||
const handleSaveAll = async () => {
|
|
||||||
try {
|
|
||||||
await assignMutation.mutateAsync({
|
|
||||||
roleId,
|
|
||||||
permissionIds: selectedPermissions,
|
|
||||||
});
|
|
||||||
toast.success("Cập nhật quyền thành công!");
|
|
||||||
setHasChanges(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Cập nhật quyền thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoading = roleLoading || rolePermissionsLoading || permissionsLoading;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 flex items-center justify-center h-64">
|
|
||||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
||||||
Chỉnh sửa quyền:
|
|
||||||
<Badge variant="secondary" className="text-lg">
|
|
||||||
{role?.roleName}
|
|
||||||
</Badge>
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Quản lý quyền hạn của role này
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Quay lại
|
|
||||||
</Button>
|
|
||||||
{hasChanges && (
|
|
||||||
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{assignMutation.isPending ? "Đang lưu..." : "Lưu thay đổi"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Role Info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" /> Thông tin Role
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-muted-foreground">Tên Role</span>
|
|
||||||
<p className="font-medium">{role?.roleName}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-muted-foreground">Độ ưu tiên</span>
|
|
||||||
<p className="font-medium">{role?.priority}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-muted-foreground">Số quyền đã gán</span>
|
|
||||||
<p className="font-medium">{selectedPermissions.length}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Quyền hạn</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Tick chọn để bật/tắt quyền ({selectedPermissions.length} đang được gán)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ScrollArea className="h-[500px] pr-4">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.entries(groupedPermissions).map(([category, perms]) => (
|
|
||||||
<div key={category} className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between border-b pb-2">
|
|
||||||
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
|
|
||||||
{category}
|
|
||||||
</h4>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectAll(perms)}
|
|
||||||
>
|
|
||||||
{perms.every((p) => selectedPermissions.includes(getPermId(p)))
|
|
||||||
? "Bỏ tất cả"
|
|
||||||
: "Chọn tất cả"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{perms.map((perm) => {
|
|
||||||
const permValue = getPermId(perm);
|
|
||||||
const isChecked = selectedPermissions.includes(permValue);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={permValue}
|
|
||||||
className={`flex items-center space-x-2 p-2 rounded border hover:bg-muted/50 transition-colors ${
|
|
||||||
isChecked ? "bg-primary/5 border-primary/30" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`perm-${permValue}`}
|
|
||||||
checked={isChecked}
|
|
||||||
onCheckedChange={() => handleTogglePermission(permValue)}
|
|
||||||
disabled={toggleMutation.isPending}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`perm-${permValue}`}
|
|
||||||
className="text-sm cursor-pointer flex-1"
|
|
||||||
>
|
|
||||||
{perm.name}
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
({permValue})
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
{hasChanges && (
|
|
||||||
<div className="flex justify-end gap-2 sticky bottom-4 bg-background p-4 rounded-lg border shadow-lg">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
// Reset to original - use permissionEnum as identifier
|
|
||||||
const checkedPermissions = (rolePermissions as PermissionOnRole[])
|
|
||||||
.filter((p) => p.isChecked === 1)
|
|
||||||
.map((p) => p.permissionEnum);
|
|
||||||
setSelectedPermissions(checkedPermissions);
|
|
||||||
setHasChanges(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Hủy thay đổi
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSaveAll} disabled={assignMutation.isPending}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{assignMutation.isPending ? "Đang lưu..." : "Lưu tất cả thay đổi"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,273 +0,0 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useGetPermissionList, useCreateRole } from "@/hooks/queries";
|
|
||||||
import { useState, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Shield, ArrowLeft, Save } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { Permission } from "@/types/permission";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/role/create/")({
|
|
||||||
component: CreateRoleComponent,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý role", path: "/role" },
|
|
||||||
{ title: "Tạo role mới", path: "/role/create" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function CreateRoleComponent() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: permissions = [], isLoading: permissionsLoading } = useGetPermissionList();
|
|
||||||
const createMutation = useCreateRole();
|
|
||||||
|
|
||||||
const [roleName, setRoleName] = useState("");
|
|
||||||
const [priority, setPriority] = useState(0);
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<number[]>([]);
|
|
||||||
|
|
||||||
// Helper to get unique identifier for permission
|
|
||||||
const getPermValue = (perm: Permission) => perm.value ?? perm.id ?? 0;
|
|
||||||
|
|
||||||
// Group permissions by parent (category)
|
|
||||||
const groupedPermissions = useMemo(() => {
|
|
||||||
const groups: Record<string, Permission[]> = {};
|
|
||||||
const permissionList = Array.isArray(permissions) ? permissions : [];
|
|
||||||
|
|
||||||
// First pass: identify all parent categories
|
|
||||||
const parentPermissions: Permission[] = [];
|
|
||||||
const childPermissions: Permission[] = [];
|
|
||||||
|
|
||||||
permissionList.forEach((perm: Permission) => {
|
|
||||||
const permValue = perm.value ?? perm.enum ?? 0;
|
|
||||||
const isParent = permValue % 10 === 0;
|
|
||||||
if (isParent) {
|
|
||||||
parentPermissions.push(perm);
|
|
||||||
groups[perm.name] = [];
|
|
||||||
} else {
|
|
||||||
childPermissions.push(perm);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second pass: assign children to parent categories
|
|
||||||
childPermissions.forEach((perm: Permission) => {
|
|
||||||
const permValue = perm.value ?? perm.enum ?? 0;
|
|
||||||
const parentEnum = Math.floor(permValue / 10) * 10;
|
|
||||||
const parent = parentPermissions.find((p: Permission) => (p.value ?? p.enum) === parentEnum);
|
|
||||||
const parentName = parent?.name || "Khác";
|
|
||||||
if (!groups[parentName]) {
|
|
||||||
groups[parentName] = [];
|
|
||||||
}
|
|
||||||
groups[parentName].push(perm);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Third pass: add parent permissions that have no children as selectable items
|
|
||||||
// (like ALLOW_ALL which is value 0 with no children)
|
|
||||||
parentPermissions.forEach((parent) => {
|
|
||||||
const parentValue = parent.value ?? parent.enum ?? 0;
|
|
||||||
// Check if this parent has any children
|
|
||||||
const hasChildren = childPermissions.some((child) => {
|
|
||||||
const childValue = child.value ?? child.enum ?? 0;
|
|
||||||
return Math.floor(childValue / 10) * 10 === parentValue;
|
|
||||||
});
|
|
||||||
// If no children, add the parent itself as a selectable item
|
|
||||||
if (!hasChildren) {
|
|
||||||
groups[parent.name].push(parent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove empty groups
|
|
||||||
Object.keys(groups).forEach((key) => {
|
|
||||||
if (groups[key].length === 0) {
|
|
||||||
delete groups[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}, [permissions]);
|
|
||||||
|
|
||||||
const handleTogglePermission = (permissionValue: number) => {
|
|
||||||
setSelectedPermissions((prev) =>
|
|
||||||
prev.includes(permissionValue)
|
|
||||||
? prev.filter((v) => v !== permissionValue)
|
|
||||||
: [...prev, permissionValue]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = (categoryPermissions: Permission[]) => {
|
|
||||||
const allValues = categoryPermissions.map((p) => getPermValue(p));
|
|
||||||
const allSelected = allValues.every((v) => selectedPermissions.includes(v));
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
setSelectedPermissions((prev) =>
|
|
||||||
prev.filter((v) => !allValues.includes(v))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setSelectedPermissions((prev) => [...new Set([...prev, ...allValues])]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!roleName.trim()) {
|
|
||||||
toast.error("Vui lòng nhập tên role!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync({
|
|
||||||
RoleName: roleName,
|
|
||||||
Priority: priority,
|
|
||||||
PermissionIds: selectedPermissions,
|
|
||||||
});
|
|
||||||
toast.success("Tạo role thành công!");
|
|
||||||
navigate({ to: "/role" });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Tạo role thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 space-y-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">Tạo Role mới</h1>
|
|
||||||
<p className="text-muted-foreground mt-2">
|
|
||||||
Tạo vai trò mới và gán quyền hạn
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/role" })}>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Quay lại
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
{/* Role Info */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Shield className="h-5 w-5" /> Thông tin Role
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Nhập thông tin cơ bản của role
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="roleName">Tên Role *</Label>
|
|
||||||
<Input
|
|
||||||
id="roleName"
|
|
||||||
value={roleName}
|
|
||||||
onChange={(e) => setRoleName(e.target.value)}
|
|
||||||
placeholder="Nhập tên role..."
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="priority">Độ ưu tiên</Label>
|
|
||||||
<Input
|
|
||||||
id="priority"
|
|
||||||
type="number"
|
|
||||||
value={priority}
|
|
||||||
onChange={(e) => setPriority(Number(e.target.value))}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Permissions Selection */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Chọn quyền hạn</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Chọn các quyền mà role này được phép thực hiện ({selectedPermissions.length} đã chọn)
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{permissionsLoading ? (
|
|
||||||
<div className="text-center py-4">Đang tải danh sách quyền...</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="h-[400px] pr-4">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{Object.entries(groupedPermissions).map(([category, perms]) => (
|
|
||||||
<div key={category} className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="font-semibold text-sm text-muted-foreground uppercase">
|
|
||||||
{category}
|
|
||||||
</h4>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleSelectAll(perms)}
|
|
||||||
>
|
|
||||||
{perms.every((p) => selectedPermissions.includes(getPermValue(p)))
|
|
||||||
? "Bỏ tất cả"
|
|
||||||
: "Chọn tất cả"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
|
||||||
{perms.map((perm) => {
|
|
||||||
const permValue = getPermValue(perm);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={permValue}
|
|
||||||
className="flex items-center space-x-2 p-2 rounded border hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={`perm-${permValue}`}
|
|
||||||
checked={selectedPermissions.includes(permValue)}
|
|
||||||
onCheckedChange={() => handleTogglePermission(permValue)}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`perm-${permValue}`}
|
|
||||||
className="text-sm cursor-pointer flex-1"
|
|
||||||
>
|
|
||||||
{perm.name}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate({ to: "/role" })}
|
|
||||||
>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{createMutation.isPending ? "Đang tạo..." : "Tạo Role"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
import { useGetRoleList, useDeleteRole } from "@/hooks/queries";
|
|
||||||
import { formatDate } from "@/lib/utils";
|
|
||||||
import type { TRoleResponse } from "@/types/role";
|
|
||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { RoleManagerTemplate } from "@/template/role-manager-template";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Pencil, Trash2, Shield } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/role/")({
|
|
||||||
component: RoleComponent,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{
|
|
||||||
title: "Quản lý role",
|
|
||||||
path: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Danh sách role",
|
|
||||||
path: "/role",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function RoleComponent() {
|
|
||||||
const { data: roles = [], isLoading } = useGetRoleList();
|
|
||||||
const roleList = Array.isArray(roles) ? roles : [roles];
|
|
||||||
const deleteMutation = useDeleteRole();
|
|
||||||
|
|
||||||
const handleDelete = async (id: number, roleName: string) => {
|
|
||||||
if (window.confirm(`Bạn có chắc chắn muốn xóa role "${roleName}"?`)) {
|
|
||||||
try {
|
|
||||||
await deleteMutation.mutateAsync(id);
|
|
||||||
toast.success("Xóa role thành công!");
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Xóa role thất bại!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<TRoleResponse>[] = [
|
|
||||||
{
|
|
||||||
accessorKey: "roleName",
|
|
||||||
header: () => <div className="font-bold text-center">Role</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center font-medium">{row.original.roleName}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "priority",
|
|
||||||
header: () => <div className="font-bold text-center">Độ ưu tiên</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center">{row.original.priority}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
header: () => <div className="font-bold text-center">Ngày tạo</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center">
|
|
||||||
{formatDate(row.original.createdAt)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdBy",
|
|
||||||
header: () => <div className="font-bold text-center">Người tạo</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center">{row.original.createdBy || "-"}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "updatedAt",
|
|
||||||
header: () => <div className="font-bold text-center">Ngày cập nhật</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center">
|
|
||||||
{formatDate(row.original.updatedAt)}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "updatedBy",
|
|
||||||
header: () => <div className="font-bold text-center">Người cập nhật</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-center">{row.original.updatedBy || "-"}</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
header: () => <div className="font-bold text-center">Hành động</div>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Link
|
|
||||||
to="/role/$id/edit"
|
|
||||||
params={{ id: String(row.original.id) }}
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Pencil className="h-4 w-4 mr-1" />
|
|
||||||
Sửa quyền
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(row.original.id, row.original.roleName)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
|
||||||
Xóa
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RoleManagerTemplate<TRoleResponse>
|
|
||||||
title="Quản lý Role"
|
|
||||||
description="Quản lý các vai trò và quyền hạn trong hệ thống"
|
|
||||||
data={roleList}
|
|
||||||
isLoading={isLoading}
|
|
||||||
columns={columns}
|
|
||||||
icon={Shield}
|
|
||||||
tableTitle="Danh sách Role"
|
|
||||||
tableDescription="Các vai trò trong hệ thống và quyền hạn tương ứng"
|
|
||||||
createButtonLabel="Tạo role mới"
|
|
||||||
createLink="/role/create"
|
|
||||||
enablePagination
|
|
||||||
defaultPageSize={10}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user