Compare commits
No commits in common. "main" and "feat/meshcentral" have entirely different histories.
main
...
feat/meshc
|
|
@ -1,177 +0,0 @@
|
||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
---
|
|
||||||
name: frontend-design
|
|
||||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
|
|
||||||
license: Complete terms in LICENSE.txt
|
|
||||||
---
|
|
||||||
|
|
||||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
|
||||||
|
|
||||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
|
||||||
|
|
||||||
## Design Thinking
|
|
||||||
|
|
||||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
|
||||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
|
||||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
|
||||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
|
||||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
|
||||||
|
|
||||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
|
||||||
|
|
||||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
|
||||||
- Production-grade and functional
|
|
||||||
- Visually striking and memorable
|
|
||||||
- Cohesive with a clear aesthetic point-of-view
|
|
||||||
- Meticulously refined in every detail
|
|
||||||
|
|
||||||
## Frontend Aesthetics Guidelines
|
|
||||||
|
|
||||||
Focus on:
|
|
||||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
|
||||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
|
||||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
|
||||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
|
||||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
|
||||||
|
|
||||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
|
||||||
|
|
||||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
|
||||||
|
|
||||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
|
||||||
|
|
||||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-test:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.14.0
|
|
||||||
cache: npm
|
|
||||||
|
|
||||||
- name: Install deps
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: npm run test -- --passWithNoTests
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
name: Deploy Staging (Docker)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Generate build env
|
|
||||||
env:
|
|
||||||
VITE_API_MESH: ${{ secrets.VITE_API_MESH }}
|
|
||||||
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY: ${{ secrets.VITE_ENABLE_MESH_DIRECT_AFTER_PROXY }}
|
|
||||||
run: |
|
|
||||||
cat > .env.production <<EOF
|
|
||||||
VITE_API_MESH=$VITE_API_MESH
|
|
||||||
VITE_ENABLE_MESH_DIRECT_AFTER_PROXY=$VITE_ENABLE_MESH_DIRECT_AFTER_PROXY
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Build Image
|
|
||||||
run: |
|
|
||||||
IMAGE="${{ secrets.IMAGE_NAME }}"
|
|
||||||
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
|
|
||||||
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
|
||||||
docker build --build-arg NODE_VERSION=22.14.0 -t $IMAGE:staging-${{ github.sha }} .
|
|
||||||
|
|
||||||
- name: Deploy local (docker compose)
|
|
||||||
run: |
|
|
||||||
IMAGE="${{ secrets.IMAGE_NAME }}"
|
|
||||||
if [ -z "$IMAGE" ]; then IMAGE="ttmt-frontend"; fi
|
|
||||||
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
|
|
||||||
COMPOSE_FILE=/home/compmanage/docker-compose.yml
|
|
||||||
OVERRIDE_FILE=/tmp/ttmt-frontend.override.yml
|
|
||||||
cat > $OVERRIDE_FILE <<EOF
|
|
||||||
services:
|
|
||||||
frontend:
|
|
||||||
image: $IMAGE:staging-${{ github.sha }}
|
|
||||||
EOF
|
|
||||||
docker rm -f ttmt-frontend || true
|
|
||||||
docker compose -f $COMPOSE_FILE -f $OVERRIDE_FILE up -d --no-deps --force-recreate frontend
|
|
||||||
rm -f $OVERRIDE_FILE
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,4 +8,3 @@ count.txt
|
||||||
.nitro
|
.nitro
|
||||||
.tanstack
|
.tanstack
|
||||||
.vscode/
|
.vscode/
|
||||||
plans/
|
|
||||||
|
|
@ -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;" ]
|
||||||
341
HOOKS_USAGE_GUIDE.md
Normal file
341
HOOKS_USAGE_GUIDE.md
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
# TanStack Query Hooks Documentation
|
||||||
|
|
||||||
|
Tất cả các API đã được tách riêng thành TanStack Query hooks trong folder `src/hooks/queries/`.
|
||||||
|
|
||||||
|
## Cấu trúc
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/queries/
|
||||||
|
├── index.ts # Export tất cả hooks
|
||||||
|
├── useAuthQueries.ts # Auth hooks
|
||||||
|
├── useAppVersionQueries.ts # App/Software hooks
|
||||||
|
├── useDeviceCommQueries.ts # Device communication hooks
|
||||||
|
└── useCommandQueries.ts # Command hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cách Sử Dụng
|
||||||
|
|
||||||
|
### 1. Auth Queries (Xác thực)
|
||||||
|
|
||||||
|
#### Đăng nhập
|
||||||
|
```tsx
|
||||||
|
import { useLogin } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const loginMutation = useLogin()
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
try {
|
||||||
|
await loginMutation.mutateAsync({
|
||||||
|
username: 'user',
|
||||||
|
password: 'password'
|
||||||
|
})
|
||||||
|
// Tự động lưu token vào localStorage
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loginMutation.isPending}
|
||||||
|
>
|
||||||
|
{loginMutation.isPending ? 'Đang đăng nhập...' : 'Đăng nhập'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Đăng xuất
|
||||||
|
```tsx
|
||||||
|
import { useLogout } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function LogoutButton() {
|
||||||
|
const logoutMutation = useLogout()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button onClick={() => logoutMutation.mutate()}>
|
||||||
|
Đăng xuất
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Kiểm tra phiên
|
||||||
|
```tsx
|
||||||
|
import { usePing } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function CheckSession() {
|
||||||
|
const { data, isLoading } = usePing(token, true)
|
||||||
|
|
||||||
|
if (isLoading) return <div>Checking...</div>
|
||||||
|
return <div>Session: {data?.message}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Thay đổi mật khẩu
|
||||||
|
```tsx
|
||||||
|
import { useChangePassword } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function ChangePasswordForm() {
|
||||||
|
const changePasswordMutation = useChangePassword()
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await changePasswordMutation.mutateAsync({
|
||||||
|
currentPassword: 'old',
|
||||||
|
newPassword: 'new'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleSubmit}>Thay đổi</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. App Version Queries (Phần mềm/Agent)
|
||||||
|
|
||||||
|
#### Lấy danh sách agent
|
||||||
|
```tsx
|
||||||
|
import { useGetAgentVersion } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function AgentList() {
|
||||||
|
const { data: agents, isLoading } = useGetAgentVersion()
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading...</div>
|
||||||
|
return <div>{agents?.length} agents</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Lấy danh sách phần mềm
|
||||||
|
```tsx
|
||||||
|
import { useGetSoftwareList } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function SoftwareList() {
|
||||||
|
const { data: software, isLoading } = useGetSoftwareList()
|
||||||
|
|
||||||
|
return software?.map(item => <div key={item.id}>{item.name}</div>)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Upload file
|
||||||
|
```tsx
|
||||||
|
import { useUploadSoftware } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function UploadForm() {
|
||||||
|
const uploadMutation = useUploadSoftware()
|
||||||
|
|
||||||
|
const handleUpload = async (file: File) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
await uploadMutation.mutateAsync({
|
||||||
|
formData,
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
const percent = (event.loaded / event.total) * 100
|
||||||
|
console.log(`Upload: ${percent}%`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <input type="file" onChange={(e) => e.target.files && handleUpload(e.target.files[0])} />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Quản lý blacklist
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
useGetBlacklist,
|
||||||
|
useAddBlacklist,
|
||||||
|
useDeleteBlacklist
|
||||||
|
} from '@/hooks/queries'
|
||||||
|
|
||||||
|
function BlacklistManager() {
|
||||||
|
const { data: blacklist } = useGetBlacklist()
|
||||||
|
const addMutation = useAddBlacklist()
|
||||||
|
const deleteMutation = useDeleteBlacklist()
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
await addMutation.mutateAsync({ appId: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (appId: number) => {
|
||||||
|
await deleteMutation.mutateAsync(appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blacklist?.map(item => (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.name}
|
||||||
|
<button onClick={() => handleDelete(item.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={handleAdd}>Add</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Device Communication Queries
|
||||||
|
|
||||||
|
#### Lấy danh sách phòng
|
||||||
|
```tsx
|
||||||
|
import { useGetRoomList } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function RoomSelector() {
|
||||||
|
const { data: rooms } = useGetRoomList()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select>
|
||||||
|
{rooms?.map(room => (
|
||||||
|
<option key={room.id} value={room.id}>{room.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Lấy thiết bị trong phòng
|
||||||
|
```tsx
|
||||||
|
import { useGetDeviceFromRoom } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function DeviceList({ roomName }: { roomName: string }) {
|
||||||
|
const { data: devices, isLoading } = useGetDeviceFromRoom(roomName, true)
|
||||||
|
|
||||||
|
if (isLoading) return <div>Loading devices...</div>
|
||||||
|
|
||||||
|
return devices?.map(device => (
|
||||||
|
<div key={device.id}>{device.name}</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Gửi lệnh
|
||||||
|
```tsx
|
||||||
|
import { useSendCommand } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function CommandForm() {
|
||||||
|
const sendMutation = useSendCommand()
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
await sendMutation.mutateAsync({
|
||||||
|
roomName: 'Room A',
|
||||||
|
data: { command: 'dir' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleSend}>Gửi lệnh</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cài đặt phần mềm
|
||||||
|
```tsx
|
||||||
|
import { useInstallMsi } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function InstallSoftware() {
|
||||||
|
const installMutation = useInstallMsi()
|
||||||
|
|
||||||
|
const handleInstall = async () => {
|
||||||
|
await installMutation.mutateAsync({
|
||||||
|
roomName: 'Room A',
|
||||||
|
data: { msiFileId: 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleInstall}>Cài đặt</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Command Queries
|
||||||
|
|
||||||
|
#### Lấy danh sách lệnh
|
||||||
|
```tsx
|
||||||
|
import { useGetCommandList } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function CommandList() {
|
||||||
|
const { data: commands } = useGetCommandList()
|
||||||
|
|
||||||
|
return commands?.map(cmd => <div key={cmd.id}>{cmd.name}</div>)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Thêm lệnh
|
||||||
|
```tsx
|
||||||
|
import { useAddCommand } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function AddCommandForm() {
|
||||||
|
const addMutation = useAddCommand()
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
await addMutation.mutateAsync({
|
||||||
|
name: 'My Command',
|
||||||
|
command: 'echo hello'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <button onClick={handleAdd}>Add Command</button>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lợi ích
|
||||||
|
|
||||||
|
1. **Automatic Caching** - TanStack Query tự động cache dữ liệu
|
||||||
|
2. **Background Refetching** - Cập nhật dữ liệu trong background
|
||||||
|
3. **Stale Time Management** - Kiểm soát thời gian dữ liệu còn "fresh"
|
||||||
|
4. **Automatic Invalidation** - Tự động update dữ liệu sau mutations
|
||||||
|
5. **Deduplication** - Gộp các request giống nhau
|
||||||
|
6. **Error Handling** - Xử lý lỗi tập trung
|
||||||
|
7. **Loading States** - Tracking loading/pending/error states
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Dependent Queries
|
||||||
|
```tsx
|
||||||
|
function DeviceDetails({ deviceId }: { deviceId: number }) {
|
||||||
|
const { data: device } = useGetDeviceFromRoom(deviceId, true)
|
||||||
|
|
||||||
|
// Chỉ fetch khi có device
|
||||||
|
const { data: status } = useGetClientFolderStatus(
|
||||||
|
device?.roomName,
|
||||||
|
!!device
|
||||||
|
)
|
||||||
|
|
||||||
|
return <div>{status?.path}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prefetching
|
||||||
|
```tsx
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useGetSoftwareList } from '@/hooks/queries'
|
||||||
|
|
||||||
|
function PrefetchOnHover() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ['app-version', 'software'],
|
||||||
|
queryFn: () => useGetSoftwareList
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div onMouseEnter={handleMouseEnter}>Hover me</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration từ cách cũ
|
||||||
|
|
||||||
|
**Trước:**
|
||||||
|
```tsx
|
||||||
|
const { data } = useQueryData({
|
||||||
|
queryKey: ["software-version"],
|
||||||
|
url: API_ENDPOINTS.APP_VERSION.GET_SOFTWARE,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sau:**
|
||||||
|
```tsx
|
||||||
|
const { data } = useGetSoftwareList()
|
||||||
|
```
|
||||||
|
|
||||||
|
Đơn giản hơn, type-safe hơn, và dễ bảo trì hơn!
|
||||||
1086
INTEGRATION_GUIDE_VI.md
Normal file
1086
INTEGRATION_GUIDE_VI.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
|
|
||||||
200
nginx/nginx.conf
200
nginx/nginx.conf
|
|
@ -1,210 +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:8080;
|
||||||
# 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:8443;
|
|
||||||
|
|
||||||
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://100.66.170.15:8080;
|
||||||
|
|
||||||
client_max_body_size 900M;
|
# Cho phép upload file lớn (vd: 200MB)
|
||||||
|
client_max_body_size 200M;
|
||||||
|
|
||||||
|
# Truyền thẳng stream sang backend
|
||||||
proxy_request_buffering off;
|
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;
|
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 via Tailscale HTTPS so MeshCentral receives
|
|
||||||
# requests over TLS and generates correct absolute URLs based on $meshhost.
|
|
||||||
proxy_pass https://soict-overleaf.tailc51e09.ts.net:8443/$1$is_args$args;
|
|
||||||
proxy_ssl_verify off;
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
|
||||||
|
|
||||||
proxy_set_header Host $meshhost;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
|
|
||||||
# Keep browser navigation under /api/meshcentral/proxy/*.
|
|
||||||
proxy_redirect ~^https?://[^/]+(/.*)$ /api/meshcentral/proxy$1;
|
|
||||||
proxy_redirect ~^(/.*)$ /api/meshcentral/proxy$1;
|
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
|
|
||||||
# FE production currently builds mesh proxy path as /meshapi/api/meshcentral/proxy/...
|
|
||||||
location = /meshapi/api/meshcentral/proxy {
|
|
||||||
return 301 /meshapi/api/meshcentral/proxy/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ^~ /meshapi/api/meshcentral/proxy/ {
|
|
||||||
# Legacy frontend path -> backend MeshCentralProxyController
|
|
||||||
rewrite ^/meshapi/api/meshcentral/proxy/(.*)$ /$1 break;
|
|
||||||
proxy_pass http://$backend_server;
|
|
||||||
proxy_cookie_path / "/; HTTPOnly; Secure; SameSite=None";
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_set_header X-Forwarded-Port 443;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
proxy_set_header X-Forwarded-Host $host;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_buffering off;
|
|
||||||
proxy_read_timeout 3600s;
|
|
||||||
proxy_send_timeout 3600s;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
560
package-lock.json
generated
560
package-lock.json
generated
|
|
@ -18,7 +18,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",
|
||||||
|
|
@ -26,7 +25,6 @@
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@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/react-virtual": "^3.13.26",
|
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
@ -38,7 +36,6 @@
|
||||||
"radix-ui": "^1.4.3",
|
"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",
|
||||||
|
|
@ -53,7 +50,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",
|
||||||
|
|
@ -1120,9 +1116,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.14",
|
"version": "1.19.10",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||||
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2857,40 +2853,6 @@
|
||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
|
||||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^11.0.0",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
|
||||||
"version": "11.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
|
||||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -3198,16 +3160,6 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||||
|
|
@ -3621,22 +3573,6 @@
|
||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
|
||||||
"version": "3.13.26",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.26.tgz",
|
|
||||||
"integrity": "sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/virtual-core": "3.16.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/router-core": {
|
"node_modules/@tanstack/router-core": {
|
||||||
"version": "1.129.8",
|
"version": "1.129.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.129.8.tgz",
|
||||||
|
|
@ -3801,15 +3737,6 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/virtual-core": {
|
|
||||||
"version": "3.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.16.0.tgz",
|
|
||||||
"integrity": "sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==",
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tanstack/virtual-file-routes": {
|
"node_modules/@tanstack/virtual-file-routes": {
|
||||||
"version": "1.129.7",
|
"version": "1.129.7",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.129.7.tgz",
|
||||||
|
|
@ -3989,60 +3916,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-array": {
|
|
||||||
"version": "3.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
|
||||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-color": {
|
|
||||||
"version": "3.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-ease": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-interpolate": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-color": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-path": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-scale": {
|
|
||||||
"version": "4.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
|
||||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-time": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-shape": {
|
|
||||||
"version": "3.1.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
|
||||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-path": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-time": {
|
|
||||||
"version": "3.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
|
||||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/d3-timer": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="
|
|
||||||
},
|
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -4095,23 +3968,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
|
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-basic-ssl": {
|
|
||||||
"version": "2.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz",
|
|
||||||
"integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
|
|
@ -4434,37 +4290,13 @@
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.16.1",
|
"version": "1.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
"integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.16.0",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"https-proxy-agent": "^5.0.1",
|
"proxy-from-env": "^1.1.0"
|
||||||
"proxy-from-env": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/axios/node_modules/agent-base": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/axios/node_modules/https-proxy-agent": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
|
||||||
"dependencies": {
|
|
||||||
"agent-base": "6",
|
|
||||||
"debug": "4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/babel-dead-code-elimination": {
|
"node_modules/babel-dead-code-elimination": {
|
||||||
|
|
@ -4562,9 +4394,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||||
"integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
|
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5040,116 +4872,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/data-uri-to-buffer": {
|
"node_modules/data-uri-to-buffer": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
|
@ -5204,11 +4926,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/decimal.js-light": {
|
|
||||||
"version": "2.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
|
||||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="
|
|
||||||
},
|
|
||||||
"node_modules/decode-formdata": {
|
"node_modules/decode-formdata": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-formdata/-/decode-formdata-0.9.0.tgz",
|
||||||
|
|
@ -5284,9 +5001,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/devalue": {
|
"node_modules/devalue": {
|
||||||
"version": "5.8.1",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz",
|
||||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
|
"integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg=="
|
||||||
},
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "8.0.3",
|
"version": "8.0.3",
|
||||||
|
|
@ -5420,15 +5137,6 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-toolkit": {
|
|
||||||
"version": "1.45.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
|
||||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
|
||||||
"workspaces": [
|
|
||||||
"docs",
|
|
||||||
"benchmarks"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.8",
|
"version": "0.25.8",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz",
|
||||||
|
|
@ -5512,11 +5220,6 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
|
|
||||||
},
|
|
||||||
"node_modules/eventsource": {
|
"node_modules/eventsource": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
|
@ -5611,11 +5314,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.5.2",
|
"version": "8.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "^10.2.0"
|
"ip-address": "10.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
|
|
@ -5648,9 +5351,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -5724,9 +5427,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.15.11",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|
@ -6025,9 +5728,9 @@
|
||||||
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
"integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.23",
|
"version": "4.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.4.tgz",
|
||||||
"integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==",
|
"integrity": "sha512-ooiZW1Xy8rQ4oELQ++otI2T9DsKpV0M6c6cO6JGx4RTfav9poFFLlet9UMXHZnoM1yG0HWGlQLswBGX3RZmHtg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
|
|
@ -6132,15 +5835,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "10.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
|
||||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
|
|
@ -6161,18 +5855,10 @@
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
}
|
}
|
||||||
|
|
@ -6924,15 +6610,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"nanoid": "bin/nanoid.cjs"
|
"nanoid": "bin/nanoid.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -7227,9 +6914,9 @@
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
},
|
},
|
||||||
|
|
@ -7246,9 +6933,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|
@ -7263,8 +6950,9 @@
|
||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
|
|
@ -7347,12 +7035,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
|
|
@ -7375,9 +7060,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.2",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -7549,30 +7234,9 @@
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -7699,48 +7363,6 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/recharts": {
|
|
||||||
"version": "3.8.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
|
||||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
|
||||||
"workspaces": [
|
|
||||||
"www"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"decimal.js-light": "^2.5.1",
|
|
||||||
"es-toolkit": "^1.39.3",
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"immer": "^10.1.1",
|
|
||||||
"react-redux": "8.x.x || 9.x.x",
|
|
||||||
"reselect": "5.1.1",
|
|
||||||
"tiny-invariant": "^1.3.3",
|
|
||||||
"use-sync-external-store": "^1.2.2",
|
|
||||||
"victory-vendor": "^37.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
|
||||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -7762,11 +7384,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||||
},
|
},
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
|
|
||||||
},
|
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
|
|
@ -7888,9 +7505,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router/node_modules/path-to-regexp": {
|
"node_modules/router/node_modules/path-to-regexp": {
|
||||||
"version": "8.4.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
|
||||||
"integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==",
|
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
|
|
@ -8441,9 +8058,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.13",
|
"version": "7.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
|
||||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
"@isaacs/fs-minipass": "^4.0.0",
|
||||||
"chownr": "^3.0.0",
|
"chownr": "^3.0.0",
|
||||||
|
|
@ -8518,9 +8135,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -8752,9 +8370,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/unplugin/node_modules/picomatch": {
|
"node_modules/unplugin/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -8857,9 +8475,9 @@
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.2",
|
"version": "13.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
|
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|
@ -8876,31 +8494,10 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/victory-vendor": {
|
|
||||||
"version": "37.3.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
||||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/d3-array": "^3.0.3",
|
|
||||||
"@types/d3-ease": "^3.0.0",
|
|
||||||
"@types/d3-interpolate": "^3.0.1",
|
|
||||||
"@types/d3-scale": "^4.0.2",
|
|
||||||
"@types/d3-shape": "^3.1.0",
|
|
||||||
"@types/d3-time": "^3.0.0",
|
|
||||||
"@types/d3-timer": "^3.0.0",
|
|
||||||
"d3-array": "^3.1.6",
|
|
||||||
"d3-ease": "^3.0.1",
|
|
||||||
"d3-interpolate": "^3.0.1",
|
|
||||||
"d3-scale": "^4.0.2",
|
|
||||||
"d3-shape": "^3.1.0",
|
|
||||||
"d3-time": "^3.0.0",
|
|
||||||
"d3-timer": "^3.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
@ -9008,9 +8605,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite/node_modules/picomatch": {
|
"node_modules/vite/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -9092,10 +8690,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -9252,10 +8851,11 @@
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.18.3",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -30,7 +29,6 @@
|
||||||
"@tanstack/react-router": "^1.121.2",
|
"@tanstack/react-router": "^1.121.2",
|
||||||
"@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/react-virtual": "^3.13.26",
|
|
||||||
"@tanstack/router-plugin": "^1.121.2",
|
"@tanstack/router-plugin": "^1.121.2",
|
||||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
|
@ -42,7 +40,6 @@
|
||||||
"radix-ui": "^1.4.3",
|
"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",
|
||||||
|
|
@ -57,7 +54,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 After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"skills": {
|
|
||||||
"frontend-design": {
|
|
||||||
"source": "anthropics/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "skills/frontend-design/SKILL.md",
|
|
||||||
"computedHash": "516bd2154eb843a8240e43d5b285229129853114ad7075a5e141e1c08e408c84"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,226 +0,0 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { AlertTriangle, CheckCircle2, Power, PowerOff, RotateCcw, ShieldBan, XCircle } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
|
||||||
import { useExecuteSensitiveCommand, useGetSensitiveCommands } from "@/hooks/queries/useCommandQueries";
|
|
||||||
import { CommandType } from "@/types/command-registry";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface DeviceActionBarProps {
|
|
||||||
roomName: string;
|
|
||||||
selectedDevices: any[];
|
|
||||||
onClearSelection: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTIONS = [
|
|
||||||
{
|
|
||||||
type: CommandType.RESTART,
|
|
||||||
label: "Khởi động lại",
|
|
||||||
icon: Power,
|
|
||||||
variant: "outline" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: CommandType.SHUTDOWN,
|
|
||||||
label: "Tắt máy",
|
|
||||||
icon: PowerOff,
|
|
||||||
variant: "destructive" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: CommandType.TASKKILL,
|
|
||||||
label: "Kết thúc tác vụ",
|
|
||||||
icon: XCircle,
|
|
||||||
variant: "outline" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: CommandType.BLOCK,
|
|
||||||
label: "Chặn",
|
|
||||||
icon: ShieldBan,
|
|
||||||
variant: "outline" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: CommandType.RESET,
|
|
||||||
label: "Reset",
|
|
||||||
icon: RotateCcw,
|
|
||||||
variant: "destructive" as const,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const DANGER_TYPES = new Set<CommandType>([CommandType.SHUTDOWN, CommandType.RESET]);
|
|
||||||
|
|
||||||
export function DeviceActionBar({
|
|
||||||
roomName,
|
|
||||||
selectedDevices,
|
|
||||||
onClearSelection,
|
|
||||||
}: DeviceActionBarProps) {
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
const [activeType, setActiveType] = useState<CommandType | null>(null);
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
|
||||||
|
|
||||||
const getMachineNumber = useMachineNumber();
|
|
||||||
|
|
||||||
const { data: sensitiveCommands = [] } = useGetSensitiveCommands();
|
|
||||||
const executeSensitiveMutation = useExecuteSensitiveCommand();
|
|
||||||
|
|
||||||
const commandsByType = useMemo(() => {
|
|
||||||
return (Object.values(CommandType) as Array<number | string>)
|
|
||||||
.filter((value) => typeof value === "number")
|
|
||||||
.reduce((acc: Record<number, any[]>, type) => {
|
|
||||||
acc[type as number] = (sensitiveCommands || []).filter(
|
|
||||||
(command: any) => Number(command.command) === Number(type)
|
|
||||||
);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<number, any[]>);
|
|
||||||
}, [sensitiveCommands]);
|
|
||||||
|
|
||||||
const selectedCount = selectedDevices.length;
|
|
||||||
const activeCommand = activeType ? commandsByType[activeType]?.[0] : null;
|
|
||||||
|
|
||||||
const buildDeviceLabel = (device: any) => {
|
|
||||||
const number = getMachineNumber(device?.id || "");
|
|
||||||
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
|
|
||||||
if (number > 0) {
|
|
||||||
return `#${number}${ipAddress ? ` (${ipAddress})` : ""}`;
|
|
||||||
}
|
|
||||||
return `${device?.id ?? ""}${ipAddress ? ` (${ipAddress})` : ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openConfirm = (type: CommandType) => {
|
|
||||||
if (!commandsByType[type]?.length) {
|
|
||||||
toast.error("Chưa có lệnh phù hợp cho thao tác này.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveType(type);
|
|
||||||
setConfirmOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (isExecuting) return;
|
|
||||||
setConfirmOpen(false);
|
|
||||||
setActiveType(null);
|
|
||||||
setPassword("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!activeCommand || !activeType) return;
|
|
||||||
if (!password.trim()) {
|
|
||||||
toast.error("Vui lòng nhập mật khẩu xác nhận.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsExecuting(true);
|
|
||||||
try {
|
|
||||||
await executeSensitiveMutation.mutateAsync({
|
|
||||||
roomName,
|
|
||||||
command: activeCommand.commandName,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
toast.success(`Đã gửi lệnh: ${activeCommand.commandName}`);
|
|
||||||
handleClose();
|
|
||||||
onClearSelection();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Execute command error:", error);
|
|
||||||
toast.error("Lỗi khi gửi lệnh!");
|
|
||||||
} finally {
|
|
||||||
setIsExecuting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="sticky bottom-4 z-30">
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border bg-background/95 px-4 py-3 shadow-lg backdrop-blur sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
|
||||||
Đã chọn {selectedCount} thiết bị
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{ACTIONS.map((action) => {
|
|
||||||
const Icon = action.icon;
|
|
||||||
const isDisabled = !commandsByType[action.type]?.length;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={action.type}
|
|
||||||
variant={action.variant}
|
|
||||||
size="sm"
|
|
||||||
disabled={isDisabled}
|
|
||||||
onClick={() => openConfirm(action.type)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={onClearSelection}>
|
|
||||||
Bỏ chọn
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog open={confirmOpen} onOpenChange={handleClose}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
|
||||||
Xác nhận thực thi lệnh
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-left space-y-3">
|
|
||||||
<p>
|
|
||||||
Bạn có chắc chắn muốn thực thi lệnh{" "}
|
|
||||||
<strong>{activeCommand?.commandName ?? ""}</strong>?
|
|
||||||
</p>
|
|
||||||
{DANGER_TYPES.has(activeType ?? CommandType.RESTART) && (
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
Hành động này không thể hoàn tác.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-sm font-medium">Thiết bị được chọn</div>
|
|
||||||
<ScrollArea className="max-h-40 rounded-lg border p-2">
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
{selectedDevices.map((device) => (
|
|
||||||
<div key={device.id} className="text-muted-foreground">
|
|
||||||
{buildDeviceLabel(device)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-sm font-medium">Mật khẩu</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
|
||||||
placeholder="Nhập mật khẩu để xác nhận"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="flex gap-2 sm:gap-3">
|
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isExecuting}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={DANGER_TYPES.has(activeType ?? CommandType.RESTART) ? "destructive" : "default"}
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={isExecuting}
|
|
||||||
>
|
|
||||||
{isExecuting ? "Đang gửi..." : "Xác nhận"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -125,15 +125,6 @@ export function DeviceSearchDialog({
|
||||||
onClose();
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
|
<DialogContent className="max-w-none w-[95vw] max-h-[90vh]">
|
||||||
|
|
@ -165,31 +156,11 @@ export function DeviceSearchDialog({
|
||||||
const isExpanded = expandedRoom === room.name;
|
const isExpanded = expandedRoom === room.name;
|
||||||
const isLoading = loadingRoom === room.name;
|
const isLoading = loadingRoom === room.name;
|
||||||
const devices = roomDevices[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 =
|
const allSelected =
|
||||||
sortedDevices.length > 0 &&
|
devices.length > 0 &&
|
||||||
sortedDevices.every((d) => selected.includes(d.id));
|
devices.every((d) => selected.includes(d.id));
|
||||||
const someSelected = sortedDevices.some((d) => selected.includes(d.id));
|
const someSelected = devices.some((d) => selected.includes(d.id));
|
||||||
const selectedCount = sortedDevices.filter((d) =>
|
const selectedCount = devices.filter((d) =>
|
||||||
selected.includes(d.id)
|
selected.includes(d.id)
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
|
@ -248,7 +219,7 @@ export function DeviceSearchDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Device table - collapsible */}
|
{/* Device table - collapsible */}
|
||||||
{isExpanded && sortedDevices.length > 0 && (
|
{isExpanded && devices.length > 0 && (
|
||||||
<div className="border-t bg-muted/20 overflow-x-auto">
|
<div className="border-t bg-muted/20 overflow-x-auto">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead className="bg-muted/50 border-b sticky top-0">
|
<thead className="bg-muted/50 border-b sticky top-0">
|
||||||
|
|
@ -272,7 +243,7 @@ export function DeviceSearchDialog({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedDevices.map((device) => (
|
{devices.map((device) => (
|
||||||
<tr
|
<tr
|
||||||
key={device.id}
|
key={device.id}
|
||||||
className="border-b last:border-b-0 hover:bg-muted/50"
|
className="border-b last:border-b-0 hover:bg-muted/50"
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
|
||||||
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
|
// All rendered commands are sourced from sensitiveCommands — send via sensitive mutation
|
||||||
await executeSensitiveMutation.mutateAsync({
|
await executeSensitiveMutation.mutateAsync({
|
||||||
roomName,
|
roomName,
|
||||||
command: confirmDialog.command.commandName,
|
command: confirmDialog.command.commandContent,
|
||||||
password: sensitivePassword,
|
password: sensitivePassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -205,7 +205,7 @@ export function CommandActionButtons({ roomName, selectedDevices = [] }: Command
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-2 flex-wrap items-center">
|
<div className="flex gap-2 flex-nowrap overflow-x-auto items-center whitespace-nowrap">
|
||||||
{Object.values(CommandType)
|
{Object.values(CommandType)
|
||||||
.filter((value) => typeof value === "number")
|
.filter((value) => typeof value === "number")
|
||||||
.map((commandType) => renderCommandButton(commandType as CommandType))}
|
.map((commandType) => renderCommandButton(commandType as CommandType))}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,30 @@
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Monitor, Wifi, WifiOff, Loader2, Maximize2, X } from "lucide-react";
|
import { Monitor, Wifi, WifiOff, Loader2 } from "lucide-react";
|
||||||
import { useState, type MouseEvent } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FolderStatusPopover } from "../folder-status-popover";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
import { useGetClientFolderStatusForDevice } from "@/hooks/queries";
|
||||||
import type { ClientFolderStatus } from "@/types/folder";
|
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({
|
export function ComputerCard({
|
||||||
device,
|
device,
|
||||||
position,
|
position,
|
||||||
folderStatus,
|
folderStatus,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
}: {
|
||||||
device: any | undefined;
|
device: any | undefined;
|
||||||
position: number;
|
position: number;
|
||||||
folderStatus?: ClientFolderStatus;
|
folderStatus?: ClientFolderStatus;
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
isSelected?: boolean;
|
|
||||||
onSelect?: (event: MouseEvent<HTMLElement>) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
|
||||||
const [showRemote, setShowRemote] = useState(false);
|
|
||||||
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-stretch rounded-lg border border-dashed border-muted-foreground/20 overflow-hidden w-[88px]">
|
<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="flex items-center justify-between px-1.5 py-1 bg-muted/30">
|
<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">
|
||||||
<span className="text-[11px] font-bold text-muted-foreground/50 leading-none">
|
{position}
|
||||||
{position}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center justify-center py-2 gap-0.5">
|
|
||||||
<Monitor className="h-5 w-5 text-muted-foreground/20" />
|
|
||||||
<span className="text-[10px] text-muted-foreground/40">Trống</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Monitor className="h-8 w-8 mb-1 text-muted-foreground/40" />
|
||||||
|
<span className="text-xs text-muted-foreground">Trống</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -49,42 +33,6 @@ export function ComputerCard({
|
||||||
const firstNetworkInfo = device.networkInfos?.[0];
|
const firstNetworkInfo = device.networkInfos?.[0];
|
||||||
const agentVersion = device.version;
|
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() {
|
function DeviceFolderCheck() {
|
||||||
const deviceId = device.id;
|
const deviceId = device.id;
|
||||||
const room = device.room;
|
const room = device.room;
|
||||||
|
|
@ -179,26 +127,6 @@ export function ComputerCard({
|
||||||
</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>
|
||||||
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
|
<div className="text-xs text-muted-foreground mb-1">Kiểm tra thư mục</div>
|
||||||
<DeviceFolderCheck />
|
<DeviceFolderCheck />
|
||||||
|
|
@ -209,7 +137,7 @@ export function ComputerCard({
|
||||||
<Badge
|
<Badge
|
||||||
variant={isOffline ? "destructive" : "default"}
|
variant={isOffline ? "destructive" : "default"}
|
||||||
className={`flex items-center gap-1 w-fit ${
|
className={`flex items-center gap-1 w-fit ${
|
||||||
isOffline ? "bg-slate-100 text-slate-600" : "bg-teal-50 text-teal-700"
|
isOffline ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
|
{isOffline ? <WifiOff className="h-3 w-3" /> : <Wifi className="h-3 w-3" />}
|
||||||
|
|
@ -220,117 +148,62 @@ export function ComputerCard({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Popover>
|
||||||
<Popover>
|
<PopoverTrigger asChild>
|
||||||
<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
|
<div
|
||||||
onClick={onSelect}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-stretch w-[88px] rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer select-none",
|
"absolute -top-2 -left-2 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
|
||||||
isOffline
|
isOffline ? "bg-red-500 text-white" : "bg-green-500 text-white"
|
||||||
? "border-slate-400 bg-white hover:border-slate-500"
|
|
||||||
: "border-teal-400 bg-white hover:border-teal-500",
|
|
||||||
isSelected && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Top bar: position + folder status */}
|
{position}
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between px-1.5 py-1",
|
|
||||||
isOffline ? "bg-slate-500" : "bg-teal-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-[11px] font-bold text-white leading-none"
|
|
||||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
|
||||||
>
|
|
||||||
{position}
|
|
||||||
</span>
|
|
||||||
{!isOffline && (
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="[&_button]:p-0 [&_button]:rounded [&_button]:hover:bg-teal-500 [&_svg]:h-3.5 [&_svg]:w-3.5 [&_svg]:text-white"
|
|
||||||
>
|
|
||||||
<FolderStatusPopover
|
|
||||||
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
|
||||||
status={folderStatus}
|
|
||||||
isLoading={isCheckingFolder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
{/* Folder Status Icon */}
|
||||||
<div className="flex flex-col items-center justify-center gap-0.5 py-2 px-1">
|
{device && !isOffline && (
|
||||||
<Monitor
|
<div className="absolute -top-2 -right-2">
|
||||||
className={cn(
|
<FolderStatusPopover
|
||||||
"h-5 w-5",
|
deviceId={device.networkInfos?.[0]?.macAddress || device.id}
|
||||||
isOffline ? "text-slate-300" : "text-teal-500"
|
status={folderStatus}
|
||||||
)}
|
isLoading={isCheckingFolder}
|
||||||
/>
|
/>
|
||||||
{firstNetworkInfo?.ipAddress && (
|
</div>
|
||||||
<div className="text-[9px] font-mono text-center leading-tight w-full truncate text-muted-foreground px-0.5">
|
)}
|
||||||
{firstNetworkInfo.ipAddress}
|
|
||||||
</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 && (
|
{agentVersion && (
|
||||||
<div className="text-[9px] font-mono text-center text-muted-foreground/60 leading-tight">
|
<div className="text-[10px] font-mono text-center mb-1 px-1 truncate w-full">
|
||||||
v{agentVersion}
|
v{agentVersion}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] font-semibold leading-none mt-0.5",
|
|
||||||
isOffline ? "text-slate-500" : "text-teal-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isOffline ? "Off" : "On"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</PopoverTrigger>
|
<div className="flex items-center gap-1">
|
||||||
<PopoverContent className="w-auto" side="top" align="center">
|
<span
|
||||||
<DeviceInfo />
|
className={cn(
|
||||||
</PopoverContent>
|
"text-xs font-medium",
|
||||||
</Popover>
|
isOffline ? "text-red-700" : "text-green-700"
|
||||||
|
)}
|
||||||
{showRemote && proxyUrl && (
|
>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
|
{isOffline ? "Off" : "On"}
|
||||||
<div className="relative h-[90vh] w-[90vw] overflow-hidden rounded-lg border bg-background shadow-2xl">
|
</span>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PopoverTrigger>
|
||||||
</>
|
<PopoverContent className="w-auto" side="top" align="center">
|
||||||
|
<DeviceInfo />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,77 +0,0 @@
|
||||||
// components/columns/apps-column.tsx
|
|
||||||
import type { Version } from "@/types/file";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
|
||||||
import { Check, X } from "lucide-react";
|
|
||||||
|
|
||||||
// Không gọi hook ở đây — nhận isPending từ ngoài truyền vào
|
|
||||||
export function createAppsColumns(isPending: boolean): ColumnDef<Version>[] {
|
|
||||||
return [
|
|
||||||
{ accessorKey: "version", header: "Phiên bản" },
|
|
||||||
{ accessorKey: "fileName", header: "Tên file" },
|
|
||||||
{
|
|
||||||
accessorKey: "syncFolder",
|
|
||||||
header: "Thư mục đồng bộ",
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<code className="text-xs">{(getValue() as string) || "C:/Setup"}</code>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "updatedAt",
|
|
||||||
header: () => (
|
|
||||||
<div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>
|
|
||||||
),
|
|
||||||
cell: ({ getValue }) =>
|
|
||||||
getValue()
|
|
||||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
|
||||||
: "N/A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "requestUpdateAt",
|
|
||||||
header: () => (
|
|
||||||
<div className="whitespace-normal max-w-xs">
|
|
||||||
Thời gian yêu cầu cài đặt/tải xuống
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ getValue }) =>
|
|
||||||
getValue()
|
|
||||||
? new Date(getValue() as string).toLocaleString("vi-VN")
|
|
||||||
: "N/A",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "required",
|
|
||||||
header: () => (
|
|
||||||
<div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const isRequired = row.original.isRequired;
|
|
||||||
return isRequired ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Check className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="text-sm text-green-600">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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
|
|
||||||
export interface SelectItem {
|
export interface SelectItem {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -16,7 +16,6 @@ interface SelectDialogProps {
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
items: SelectItem[];
|
items: SelectItem[];
|
||||||
selectedValues?: string[];
|
|
||||||
onConfirm: (values: string[]) => Promise<void> | void;
|
onConfirm: (values: string[]) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,18 +26,11 @@ export function SelectDialog({
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
items,
|
items,
|
||||||
selectedValues,
|
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: SelectDialogProps) {
|
}: SelectDialogProps) {
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
if (!selectedValues) return;
|
|
||||||
setSelected(selectedValues);
|
|
||||||
}, [open, selectedValues]);
|
|
||||||
|
|
||||||
const filteredItems = useMemo(() => {
|
const filteredItems = useMemo(() => {
|
||||||
return items.filter((item) =>
|
return items.filter((item) =>
|
||||||
item.label.toLowerCase().includes(search.toLowerCase())
|
item.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -32,7 +32,6 @@ export interface CommandRegistryFormData {
|
||||||
commandContent: string;
|
commandContent: string;
|
||||||
qos: 0 | 1 | 2;
|
qos: 0 | 1 | 2;
|
||||||
isRetained: boolean;
|
isRetained: boolean;
|
||||||
ttlMinutes: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zod validation schema
|
// Zod validation schema
|
||||||
|
|
@ -54,7 +53,6 @@ const commandRegistrySchema = z.object({
|
||||||
.trim(),
|
.trim(),
|
||||||
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
qos: z.union([z.literal(0), z.literal(1), z.literal(2)]),
|
||||||
isRetained: z.boolean(),
|
isRetained: z.boolean(),
|
||||||
ttlMinutes: z.number().int().min(-1, "TTL tối thiểu là -1 (vô hạn)"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const QoSLevels = [
|
const QoSLevels = [
|
||||||
|
|
@ -100,7 +98,6 @@ export function CommandRegistryForm({
|
||||||
commandContent: initialData?.commandContent || "",
|
commandContent: initialData?.commandContent || "",
|
||||||
qos: (initialData?.qos || 0) as 0 | 1 | 2,
|
qos: (initialData?.qos || 0) as 0 | 1 | 2,
|
||||||
isRetained: initialData?.isRetained || false,
|
isRetained: initialData?.isRetained || false,
|
||||||
ttlMinutes: initialData?.ttlMinutes ?? -1,
|
|
||||||
},
|
},
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -186,7 +183,6 @@ export function CommandRegistryForm({
|
||||||
<option value={CommandType.SHUTDOWN}>SHUTDOWN - Tắt máy</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.TASKKILL}>TASKKILL - Kết thúc tác vụ</option>
|
||||||
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
|
<option value={CommandType.BLOCK}>BLOCK - Chặn</option>
|
||||||
<option value={CommandType.RESET}>RESET - Đặt lại</option>
|
|
||||||
</select>
|
</select>
|
||||||
{field.state.meta.errors?.length > 0 && (
|
{field.state.meta.errors?.length > 0 && (
|
||||||
<p className="text-sm text-red-500">
|
<p className="text-sm text-red-500">
|
||||||
|
|
@ -391,31 +387,6 @@ export function CommandRegistryForm({
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
{/* TTL Minutes */}
|
|
||||||
<form.Field name="ttlMinutes">
|
|
||||||
{(field: any) => (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>TTL (Phút)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="-1"
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(Number(e.target.value))}
|
|
||||||
onBlur={field.handleBlur}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
/>
|
|
||||||
{field.state.meta.errors?.length > 0 && (
|
|
||||||
<p className="text-sm text-red-500">
|
|
||||||
{String(field.state.meta.errors[0])}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Thời gian tồn tại của lệnh retained (phút). -1 = vô hạn.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import type { LoginResquest } from "@/types/auth";
|
import type { LoginResquest } from "@/types/auth";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { buildGoogleOAuthLoginUrl, buildMicrosoftSsoLoginUrl, login } from "@/services/auth.service";
|
import { login } from "@/services/auth.service";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useRouter } from "@tanstack/react-router";
|
import { useNavigate, useRouter } from "@tanstack/react-router";
|
||||||
import { Route } from "@/routes/(auth)/login";
|
import { Route } from "@/routes/(auth)/login";
|
||||||
|
|
@ -44,24 +44,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
|
||||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
|
||||||
returnUrl.searchParams.set("provider", "google");
|
|
||||||
if (search.redirect) {
|
|
||||||
returnUrl.searchParams.set("redirect", search.redirect);
|
|
||||||
}
|
|
||||||
window.location.assign(buildGoogleOAuthLoginUrl(returnUrl.toString()));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMicrosoftLogin = () => {
|
|
||||||
const returnUrl = new URL("/oauth/callback", window.location.origin);
|
|
||||||
returnUrl.searchParams.set("provider", "azuread");
|
|
||||||
if (search.redirect) {
|
|
||||||
returnUrl.searchParams.set("redirect", search.redirect);
|
|
||||||
}
|
|
||||||
window.location.assign(buildMicrosoftSsoLoginUrl(returnUrl.toString()));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
@ -71,10 +53,10 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-6", className)}>
|
<div className={cn("flex flex-col gap-6", className)}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center flex flex-col items-center">
|
||||||
<CardTitle className="text-2xl font-semibold tracking-tight flex items-center justify-center gap-3">
|
<CardTitle className="text-xl flex items-center gap-3">
|
||||||
<img src="/soict_logo.png" alt="SOICT logo" className="h-7 w-auto object-contain" />
|
<img src="/soict_logo.png" alt="logo" className="size-20" />
|
||||||
<span>Computer Management</span>
|
<p> Computer Management</p>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Hệ thống quản lý phòng máy thực hành</CardDescription>
|
<CardDescription>Hệ thống quản lý phòng máy thực hành</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
@ -121,37 +103,6 @@ export function LoginForm({ className }: React.ComponentProps<"form">) {
|
||||||
Đăng nhập
|
Đăng nhập
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,14 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
||||||
const [isDone, setIsDone] = useState(false);
|
const [isDone, setIsDone] = useState(false);
|
||||||
|
|
||||||
// Match server allowed extensions
|
// Match server allowed extensions
|
||||||
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1", ".zip"];
|
const ALLOWED_EXTENSIONS = [".exe", ".apk", ".conf", ".json", ".xml", ".setting", ".lnk", ".url", ".seb", ".ps1"];
|
||||||
const isFileValid = (file: File) => {
|
const isFileValid = (file: File) => {
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
return ALLOWED_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
defaultValues: { files: new DataTransfer().files, newVersion: "", syncFolder: "" },
|
defaultValues: { files: new DataTransfer().files, newVersion: "" },
|
||||||
onSubmit: async ({ value }) => {
|
onSubmit: async ({ value }) => {
|
||||||
if (!value.newVersion || value.files.length === 0) {
|
if (!value.newVersion || value.files.length === 0) {
|
||||||
toast.error("Vui lòng điền đầy đủ thông tin");
|
toast.error("Vui lòng điền đầy đủ thông tin");
|
||||||
|
|
@ -49,7 +49,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
|
Array.from(value.files).forEach((f) => fd.append("FileInput", f));
|
||||||
fd.append("Version", value.newVersion);
|
fd.append("Version", value.newVersion);
|
||||||
if (value.syncFolder) fd.append("SyncFolder", value.syncFolder);
|
|
||||||
|
|
||||||
await onSubmit(fd, {
|
await onSubmit(fd, {
|
||||||
onUploadProgress: (e: AxiosProgressEvent) => {
|
onUploadProgress: (e: AxiosProgressEvent) => {
|
||||||
|
|
@ -92,23 +91,6 @@ export function UploadVersionForm({ onSubmit, closeDialog }: UploadVersionFormPr
|
||||||
)}
|
)}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
<form.Field name="syncFolder">
|
|
||||||
{(field) => (
|
|
||||||
<div>
|
|
||||||
<Label>Thư mục đồng bộ</Label>
|
|
||||||
<Input
|
|
||||||
value={field.state.value}
|
|
||||||
onChange={(e) => field.handleChange(e.target.value)}
|
|
||||||
placeholder="C:/Setup"
|
|
||||||
disabled={isUploading || isDone}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Để trống = mặc định <code>C:/Setup</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form.Field>
|
|
||||||
|
|
||||||
<form.Field name="files">
|
<form.Field name="files">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
import { useMemo, type MouseEvent } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
|
||||||
|
|
||||||
interface DeviceGridCompactProps {
|
|
||||||
devices: any[];
|
|
||||||
selectedIds?: string[];
|
|
||||||
onSelectDevice?: (
|
|
||||||
deviceId: string,
|
|
||||||
index: number,
|
|
||||||
event: MouseEvent<HTMLElement>
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeviceGridCompact({
|
|
||||||
devices,
|
|
||||||
selectedIds = [],
|
|
||||||
onSelectDevice,
|
|
||||||
}: DeviceGridCompactProps) {
|
|
||||||
const getMachineNumber = useMachineNumber();
|
|
||||||
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
return [...devices]
|
|
||||||
.map((device, index) => ({
|
|
||||||
device,
|
|
||||||
index,
|
|
||||||
number: getMachineNumber(device?.id || ""),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
|
|
||||||
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
|
|
||||||
if (aNumber !== bNumber) return aNumber - bNumber;
|
|
||||||
return a.index - b.index;
|
|
||||||
});
|
|
||||||
}, [devices, getMachineNumber]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const device = item.device;
|
|
||||||
const position = item.number > 0 ? item.number : item.index + 1;
|
|
||||||
const ipAddress = device?.networkInfos?.[0]?.ipAddress;
|
|
||||||
const version = device?.version;
|
|
||||||
const titleParts = [`#${position}`];
|
|
||||||
if (ipAddress) titleParts.push(`IP: ${ipAddress}`);
|
|
||||||
if (version) titleParts.push(`v${version}`);
|
|
||||||
const isOffline = device?.isOffline;
|
|
||||||
const isSelected = selectedSet.has(device?.id);
|
|
||||||
|
|
||||||
// last 2 octets of IP for compact display
|
|
||||||
const shortIp = ipAddress
|
|
||||||
? ipAddress.split(".").slice(-2).join(".")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={device?.id || `device-${item.index}`}
|
|
||||||
type="button"
|
|
||||||
title={titleParts.join(" | ")}
|
|
||||||
onClick={(event) => {
|
|
||||||
if (!device?.id) return;
|
|
||||||
onSelectDevice?.(device.id, index, event);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col items-stretch rounded-lg border-2 overflow-hidden transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer",
|
|
||||||
isOffline
|
|
||||||
? "border-slate-400 bg-white hover:border-slate-500"
|
|
||||||
: "border-teal-400 bg-white hover:border-teal-500",
|
|
||||||
isSelected &&
|
|
||||||
"ring-2 ring-primary ring-offset-1 ring-offset-background"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Top color bar with position number */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-between px-1.5 py-1",
|
|
||||||
isOffline ? "bg-slate-500" : "bg-teal-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-[11px] font-bold text-white leading-none"
|
|
||||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
|
||||||
>
|
|
||||||
{position}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-1.5 w-1.5 rounded-full border border-white/40",
|
|
||||||
isOffline ? "bg-slate-200" : "bg-teal-200"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex flex-col items-center justify-center gap-0.5 py-1.5 px-1">
|
|
||||||
{shortIp ? (
|
|
||||||
<span
|
|
||||||
className="font-mono text-muted-foreground truncate w-full text-center leading-tight"
|
|
||||||
style={{ fontSize: "9px" }}
|
|
||||||
>
|
|
||||||
{shortIp}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[9px] text-muted-foreground/50">—</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] font-semibold leading-none",
|
|
||||||
isOffline ? "text-slate-500" : "text-teal-600"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isOffline ? "Off" : "On"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useMemo, type MouseEvent } from "react";
|
|
||||||
import { Monitor, DoorOpen } from "lucide-react";
|
import { Monitor, DoorOpen } from "lucide-react";
|
||||||
import { ComputerCard } from "../cards/computer-card";
|
import { ComputerCard } from "../cards/computer-card";
|
||||||
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
import { useMachineNumber } from "../../hooks/useMachineNumber";
|
||||||
|
|
@ -8,146 +7,68 @@ export function DeviceGrid({
|
||||||
devices,
|
devices,
|
||||||
folderStatuses,
|
folderStatuses,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
totalSeats,
|
|
||||||
selectedIds = [],
|
|
||||||
onSelectDevice,
|
|
||||||
}: {
|
}: {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
folderStatuses?: Map<string, ClientFolderStatus>;
|
folderStatuses?: Map<string, ClientFolderStatus>;
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
totalSeats?: number;
|
|
||||||
selectedIds?: string[];
|
|
||||||
onSelectDevice?: (
|
|
||||||
deviceId: string,
|
|
||||||
index: number,
|
|
||||||
event: MouseEvent<HTMLElement>
|
|
||||||
) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
const deviceMap = new Map<number, any>();
|
||||||
const parsedDevices = devices
|
|
||||||
.map((device, index) => ({
|
|
||||||
device,
|
|
||||||
index,
|
|
||||||
number: getMachineNumber(device.id || ""),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const aNumber = a.number > 0 ? a.number : Number.MAX_SAFE_INTEGER;
|
|
||||||
const bNumber = b.number > 0 ? b.number : Number.MAX_SAFE_INTEGER;
|
|
||||||
|
|
||||||
if (aNumber !== bNumber) {
|
devices.forEach((device) => {
|
||||||
return aNumber - bNumber;
|
const number = getMachineNumber(device.id || "");
|
||||||
}
|
if (number > 0 && number <= 40) deviceMap.set(number, device);
|
||||||
|
});
|
||||||
|
|
||||||
return a.index - b.index;
|
const totalRows = 5;
|
||||||
});
|
|
||||||
|
|
||||||
const orderedDevices = parsedDevices.map((item, orderIndex) => ({
|
|
||||||
...item,
|
|
||||||
orderIndex,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const seatCount =
|
|
||||||
typeof totalSeats === "number" && totalSeats > 0 ? totalSeats : orderedDevices.length;
|
|
||||||
const rightCapacity = Math.ceil(seatCount / 2);
|
|
||||||
const inRangeCount = orderedDevices.filter(
|
|
||||||
(item) => item.number > 0 && item.number <= seatCount
|
|
||||||
).length;
|
|
||||||
const useThresholdSplit =
|
|
||||||
seatCount > 0 && inRangeCount >= Math.ceil(orderedDevices.length * 0.6);
|
|
||||||
|
|
||||||
let rightDevices = orderedDevices;
|
|
||||||
let leftDevices: typeof orderedDevices = [];
|
|
||||||
|
|
||||||
if (useThresholdSplit) {
|
|
||||||
rightDevices = orderedDevices.filter(
|
|
||||||
(item) => item.number > 0 && item.number <= rightCapacity
|
|
||||||
);
|
|
||||||
leftDevices = orderedDevices.filter((item) => item.number > rightCapacity);
|
|
||||||
|
|
||||||
const unassigned = orderedDevices.filter(
|
|
||||||
(item) => item.number <= 0 || item.number > seatCount
|
|
||||||
);
|
|
||||||
leftDevices = [...leftDevices, ...unassigned];
|
|
||||||
} else {
|
|
||||||
const splitIndex = Math.ceil(orderedDevices.length / 2);
|
|
||||||
rightDevices = orderedDevices.slice(0, splitIndex);
|
|
||||||
leftDevices = orderedDevices.slice(splitIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderDevice = (item: (typeof orderedDevices)[number]) => {
|
|
||||||
const device = item.device;
|
|
||||||
const position = item.number > 0 ? item.number : item.index + 1;
|
|
||||||
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
|
||||||
const folderStatus = folderStatuses?.get(macAddress);
|
|
||||||
const isSelected = device?.id ? selectedSet.has(device.id) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComputerCard
|
|
||||||
key={device?.id || `device-${item.index}`}
|
|
||||||
device={device}
|
|
||||||
position={position}
|
|
||||||
folderStatus={folderStatus}
|
|
||||||
isCheckingFolder={isCheckingFolder}
|
|
||||||
isSelected={isSelected}
|
|
||||||
onSelect={(event) => {
|
|
||||||
if (!device?.id) return;
|
|
||||||
onSelectDevice?.(device.id, item.orderIndex, event);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columnsPerSide = 4;
|
|
||||||
const chunkRows = <T,>(items: T[], size: number) => {
|
|
||||||
const rows: T[][] = [];
|
|
||||||
for (let i = 0; i < items.length; i += size) {
|
|
||||||
rows.push(items.slice(i, i + size));
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftRows = chunkRows(leftDevices, columnsPerSide);
|
|
||||||
const rightRows = chunkRows(rightDevices, columnsPerSide);
|
|
||||||
const totalRows = Math.max(leftRows.length, rightRows.length);
|
|
||||||
|
|
||||||
const renderPlaceholder = (key: string) => (
|
|
||||||
<div key={key} className="w-24 h-24 shrink-0" aria-hidden="true" />
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (rowIndex: number) => {
|
const renderRow = (rowIndex: number) => {
|
||||||
const leftRow = leftRows[rowIndex] ?? [];
|
// Đảo ngược: 21-40 sang trái, 1-20 sang phải
|
||||||
const rightRow = rightRows[rowIndex] ?? [];
|
const leftStart = 21 + (totalRows - 1 - rowIndex) * 4;
|
||||||
const leftFill = Math.max(0, columnsPerSide - leftRow.length);
|
const rightStart = (totalRows - 1 - rowIndex) * 4 + 1;
|
||||||
const rightFill = Math.max(0, columnsPerSide - rightRow.length);
|
|
||||||
|
|
||||||
// Cả 2 panel đều mirror: số nhỏ nhất sát divider, tăng ra ngoài
|
|
||||||
// Right: [8,7,6,5,4,3,2,1 | divider] Left: [divider | 9,10,11,12,13,14,15,16]
|
|
||||||
// Nhìn từ bàn GV (phải) sang trái: 1,2,3,4,... liên tục
|
|
||||||
const rightRowReversed = [...rightRow].reverse();
|
|
||||||
const leftRowReversed = [...leftRow].reverse();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`row-${rowIndex}`} className="flex items-center justify-center gap-3">
|
<div key={rowIndex} className="flex items-center justify-center gap-3">
|
||||||
{/* Left panel: số lớn sát divider, giảm ra ngoài trái */}
|
{/* Bên trái (21–40) */}
|
||||||
<div className="flex items-center gap-3">
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
{Array.from({ length: leftFill }).map((_, i) =>
|
const pos = leftStart + (3 - i);
|
||||||
renderPlaceholder(`left-pad-${rowIndex}-${i}`)
|
const device = deviceMap.get(pos);
|
||||||
)}
|
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||||
{leftRowReversed.map(renderDevice)}
|
const folderStatus = folderStatuses?.get(macAddress);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComputerCard
|
||||||
|
key={pos}
|
||||||
|
device={device}
|
||||||
|
position={pos}
|
||||||
|
folderStatus={folderStatus}
|
||||||
|
isCheckingFolder={isCheckingFolder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Đường chia giữa */}
|
||||||
|
<div className="w-32 flex items-center justify-center">
|
||||||
|
<div className="h-px w-full bg-border border-t-2 border-dashed" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-10 flex items-center justify-center">
|
{/* Bên phải (1–20) */}
|
||||||
<div className="h-10 w-px bg-border border-l-2 border-dashed" />
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
</div>
|
const pos = rightStart + (3 - i);
|
||||||
|
const device = deviceMap.get(pos);
|
||||||
|
const macAddress = device?.networkInfos?.[0]?.macAddress || device?.id;
|
||||||
|
const folderStatus = folderStatuses?.get(macAddress);
|
||||||
|
|
||||||
{/* Right panel: số 1 sát divider, tăng ra ngoài phải */}
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<ComputerCard
|
||||||
{rightRowReversed.map(renderDevice)}
|
key={pos}
|
||||||
{Array.from({ length: rightFill }).map((_, i) =>
|
device={device}
|
||||||
renderPlaceholder(`right-pad-${rowIndex}-${i}`)
|
position={pos}
|
||||||
)}
|
folderStatus={folderStatus}
|
||||||
</div>
|
isCheckingFolder={isCheckingFolder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -155,7 +76,7 @@ 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">
|
<div className="space-y-4">
|
||||||
{Array.from({ length: totalRows }).map((_, i) => renderRow(totalRows - 1 - i))}
|
{Array.from({ length: totalRows }).map((_, i) => renderRow(i))}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div className="flex items-center gap-3 px-6 py-4 bg-muted rounded-lg border-2 border-border">
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface RoomListPanelProps {
|
|
||||||
rooms: Room[];
|
|
||||||
activeRoomName?: string;
|
|
||||||
onSelectRoom: (roomName: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDeviceCount = (value: number) => {
|
|
||||||
if (!Number.isFinite(value)) return "0";
|
|
||||||
if (value < 1000) return String(value);
|
|
||||||
const k = value / 1000;
|
|
||||||
const needsDecimal = value < 10000 && value % 1000 !== 0;
|
|
||||||
const formatted = k.toFixed(needsDecimal ? 1 : 0).replace(/\.0$/, "");
|
|
||||||
return `${formatted}k`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function RoomListPanel({
|
|
||||||
rooms,
|
|
||||||
activeRoomName,
|
|
||||||
onSelectRoom,
|
|
||||||
}: RoomListPanelProps) {
|
|
||||||
const sortedRooms = useMemo(() => {
|
|
||||||
return [...rooms].sort((a, b) => {
|
|
||||||
const nameA = String(a?.name ?? "");
|
|
||||||
const nameB = String(b?.name ?? "");
|
|
||||||
return nameA.localeCompare(nameB, "vi", {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [rooms]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-[200px] shrink-0 overflow-hidden rounded-xl border bg-background shadow-sm">
|
|
||||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Danh sách phòng
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
{rooms.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<ScrollArea className="h-[calc(100vh-240px)] p-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{sortedRooms.length === 0 && (
|
|
||||||
<div className="px-2 py-6 text-center text-xs text-muted-foreground">
|
|
||||||
Chưa có phòng
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{sortedRooms.map((room) => {
|
|
||||||
const isActive = room.name === activeRoomName;
|
|
||||||
const hasOffline = room.numberOfOfflineDevices > 0;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={room.name}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectRoom(room.name)}
|
|
||||||
className={cn(
|
|
||||||
"w-full flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left text-sm transition-colors",
|
|
||||||
isActive
|
|
||||||
? "border-border/60 bg-background text-foreground shadow-sm"
|
|
||||||
: "border-transparent text-muted-foreground hover:bg-muted/40"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"h-2 w-2 rounded-full",
|
|
||||||
hasOffline ? "bg-red-500" : "bg-green-500"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate font-medium text-foreground">
|
|
||||||
{room.name}
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
|
||||||
{formatDeviceCount(room.numberOfDevices)}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -5,8 +5,6 @@ import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { useMemo, useRef, type MouseEvent } from "react";
|
|
||||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -18,7 +16,6 @@ import {
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
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 { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
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";
|
import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
|
|
@ -26,13 +23,6 @@ import { FolderStatusPopover } from "../folder-status-popover";
|
||||||
interface DeviceTableProps {
|
interface DeviceTableProps {
|
||||||
devices: any[];
|
devices: any[];
|
||||||
isCheckingFolder?: boolean;
|
isCheckingFolder?: boolean;
|
||||||
selectedIds?: string[];
|
|
||||||
onToggleDevice?: (
|
|
||||||
deviceId: string,
|
|
||||||
index: number,
|
|
||||||
event: MouseEvent<HTMLElement>
|
|
||||||
) => void;
|
|
||||||
onToggleAll?: (checked: boolean) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,46 +31,10 @@ interface DeviceTableProps {
|
||||||
export function DeviceTable({
|
export function DeviceTable({
|
||||||
devices,
|
devices,
|
||||||
isCheckingFolder,
|
isCheckingFolder,
|
||||||
selectedIds = [],
|
|
||||||
onToggleDevice,
|
|
||||||
onToggleAll,
|
|
||||||
}: DeviceTableProps) {
|
}: DeviceTableProps) {
|
||||||
const getMachineNumber = useMachineNumber();
|
const getMachineNumber = useMachineNumber();
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
||||||
const allSelected = devices.length > 0 && devices.every((d) => selectedSet.has(d.id));
|
|
||||||
const someSelected = devices.some((d) => selectedSet.has(d.id));
|
|
||||||
|
|
||||||
const selectionEnabled = Boolean(onToggleDevice || onToggleAll);
|
|
||||||
const selectionColumn: ColumnDef<any> = {
|
|
||||||
id: "select",
|
|
||||||
header: () => (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
||||||
onCheckedChange={(value) => onToggleAll?.(value === true)}
|
|
||||||
aria-label="Chọn tất cả"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const device = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedSet.has(device.id)}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onToggleDevice?.(device.id, row.index, event);
|
|
||||||
}}
|
|
||||||
aria-label="Chọn thiết bị"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
...(selectionEnabled ? [selectionColumn] : []),
|
|
||||||
{
|
{
|
||||||
header: "STT",
|
header: "STT",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|
@ -154,11 +108,11 @@ export function DeviceTable({
|
||||||
key={idx}
|
key={idx}
|
||||||
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
className="flex items-center gap-2 text-sm font-mono px-2 py-1 rounded bg-muted/30"
|
||||||
>
|
>
|
||||||
<span className="text-primary">*</span>
|
<span className="text-primary">•</span>
|
||||||
<code className="bg-background px-2 py-0.5 rounded">
|
<code className="bg-background px-2 py-0.5 rounded">
|
||||||
{info.macAddress ?? "-"}
|
{info.macAddress ?? "-"}
|
||||||
</code>
|
</code>
|
||||||
<span className="text-muted-foreground">-></span>
|
<span className="text-muted-foreground">→</span>
|
||||||
<code className="bg-background px-2 py-0.5 rounded">
|
<code className="bg-background px-2 py-0.5 rounded">
|
||||||
{info.ipAddress ?? "-"}
|
{info.ipAddress ?? "-"}
|
||||||
</code>
|
</code>
|
||||||
|
|
@ -217,24 +171,8 @@ export function DeviceTable({
|
||||||
initialState: { pagination: { pageSize: 16 } },
|
initialState: { pagination: { pageSize: 16 } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const rows = table.getRowModel().rows;
|
|
||||||
const rowVirtualizer = useVirtualizer({
|
|
||||||
count: rows.length,
|
|
||||||
getScrollElement: () => parentRef.current,
|
|
||||||
estimateSize: () => 72,
|
|
||||||
overscan: 8,
|
|
||||||
});
|
|
||||||
const virtualRows = rowVirtualizer.getVirtualItems() as VirtualItem[];
|
|
||||||
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
|
|
||||||
const paddingBottom =
|
|
||||||
virtualRows.length > 0
|
|
||||||
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
|
|
||||||
: 0;
|
|
||||||
const columnCount = table.getVisibleLeafColumns().length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="max-h-[600px] overflow-y-auto">
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
|
@ -248,40 +186,15 @@ export function DeviceTable({
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{paddingTop > 0 && (
|
{table.getRowModel().rows.map((row) => (
|
||||||
<TableRow>
|
<TableRow key={row.id} className="hover:bg-muted/50 transition-colors">
|
||||||
<TableCell
|
{row.getVisibleCells().map((cell) => (
|
||||||
colSpan={columnCount}
|
<TableCell key={cell.id} className="py-4">
|
||||||
className="p-0"
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
style={{ height: `${paddingTop}px` }}
|
</TableCell>
|
||||||
/>
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
))}
|
||||||
{virtualRows.map((virtualRow) => {
|
|
||||||
const row = rows[virtualRow.index];
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className="hover:bg-muted/50 transition-colors"
|
|
||||||
style={{ height: `${virtualRow.size}px` }}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="py-4">
|
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{paddingBottom > 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columnCount}
|
|
||||||
className="p-0"
|
|
||||||
style={{ height: `${paddingBottom}px` }}
|
|
||||||
/>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -6,23 +7,50 @@ function ScrollArea({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
className={cn("relative overflow-auto", className)}
|
className={cn("relative", className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
<ScrollAreaPrimitive.Viewport
|
||||||
</div>
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScrollBar({
|
function ScrollBar({
|
||||||
className,
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
return <div className={className} {...props} />
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
export { ScrollArea, ScrollBar }
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -1,48 +1,12 @@
|
||||||
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: {
|
AUTH: {
|
||||||
LOGIN: `${BASE_URL}/login`,
|
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`,
|
LOGOUT: `${BASE_URL}/logout`,
|
||||||
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
|
CHANGE_PASSWORD: `${BASE_URL}/auth/change-password`,
|
||||||
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
|
CHANGE_PASSWORD_ADMIN: `${BASE_URL}/auth/admin/change-password`,
|
||||||
|
|
@ -51,10 +15,6 @@ export const API_ENDPOINTS = {
|
||||||
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
|
CREATE_ACCOUNT: `${BASE_URL}/auth/create-account`,
|
||||||
GET_USERS_LIST: `${BASE_URL}/users-info`,
|
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
|
//agent and app api
|
||||||
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
GET_VERSION: `${BASE_URL}/AppVersion/version`,
|
||||||
|
|
@ -71,11 +31,8 @@ export const API_ENDPOINTS = {
|
||||||
//require file api
|
//require file api
|
||||||
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
|
GET_REQUIRED_FILES: `${BASE_URL}/AppVersion/requirefiles`,
|
||||||
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
|
ADD_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/add`,
|
||||||
DELETE_REQUIRED_FILE: `${BASE_URL}/AppVersion/requirefile/delete`,
|
DELETE_REQUIRED_FILE: (fileId: number) => `${BASE_URL}/AppVersion/requirefile/delete/${fileId}`,
|
||||||
DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
|
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`,
|
||||||
},
|
|
||||||
MANIFEST: {
|
|
||||||
SEND_ALL: `${BASE_URL}/Manifest/sendall`,
|
|
||||||
},
|
},
|
||||||
DEVICE_COMM: {
|
DEVICE_COMM: {
|
||||||
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,
|
||||||
|
|
@ -125,20 +82,4 @@ export const API_ENDPOINTS = {
|
||||||
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
|
TOGGLE_PERMISSION: (roleId: number, permissionId: number) =>
|
||||||
`${BASE_URL}/Role/${roleId}/permissions/${permissionId}/toggle`,
|
`${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`,
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,9 @@ export * from "./useAppVersionQueries";
|
||||||
// Device Communication Queries
|
// Device Communication Queries
|
||||||
export * from "./useDeviceCommQueries";
|
export * from "./useDeviceCommQueries";
|
||||||
|
|
||||||
// Dashboard Queries
|
|
||||||
export * from "./useDashboardQueries";
|
|
||||||
|
|
||||||
// Command Queries
|
// Command Queries
|
||||||
export * from "./useCommandQueries";
|
export * from "./useCommandQueries";
|
||||||
|
|
||||||
// Audit Queries
|
|
||||||
export * from "./useAuditQueries";
|
|
||||||
|
|
||||||
// Permission Queries
|
// Permission Queries
|
||||||
export * from "./usePermissionQueries";
|
export * from "./usePermissionQueries";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export function useDeleteRequiredFile() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteRequiredFile(data),
|
mutationFn: (fileId: number) => appVersionService.deleteRequiredFile(fileId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
|
queryKey: APP_VERSION_QUERY_KEYS.requiredFiles(),
|
||||||
|
|
@ -176,7 +176,7 @@ export function useDeleteFile() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
|
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
queryKey: APP_VERSION_QUERY_KEYS.softwareList(),
|
||||||
|
|
@ -184,12 +184,3 @@ export function useDeleteFile() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để gửi manifest
|
|
||||||
*/
|
|
||||||
export function useSendManifest() {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: () => appVersionService.sendManifest(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -113,19 +113,3 @@ export function useCreateAccount() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook để đổi one-time OAuth code lấy payload đăng nhập
|
|
||||||
*/
|
|
||||||
export function useExchangeOAuthCode() {
|
|
||||||
return useMutation<LoginResponse, any, string>({
|
|
||||||
mutationFn: (code) => authService.exchangeOAuthCode(code),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy alias for backward compatibility.
|
|
||||||
*/
|
|
||||||
export function useExchangeSsoCode() {
|
|
||||||
return useExchangeOAuthCode();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import * as deviceCommService from "@/services/device-comm.service";
|
import * as deviceCommService from "@/services/device-comm.service";
|
||||||
import type { DeviceHealthCheck } from "@/types/device";
|
import type { DeviceHealthCheck } from "@/types/device";
|
||||||
import type { ClientFolderStatus } from "@/types/folder";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
|
|
||||||
const DEVICE_COMM_QUERY_KEYS = {
|
const DEVICE_COMM_QUERY_KEYS = {
|
||||||
all: ["device-comm"] as const,
|
all: ["device-comm"] as const,
|
||||||
|
|
@ -30,7 +29,7 @@ export function useGetAllDevices(enabled = true) {
|
||||||
* Hook để lấy danh sách phòng
|
* Hook để lấy danh sách phòng
|
||||||
*/
|
*/
|
||||||
export function useGetRoomList(enabled = true) {
|
export function useGetRoomList(enabled = true) {
|
||||||
return useQuery<Room[]>({
|
return useQuery({
|
||||||
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
|
queryKey: DEVICE_COMM_QUERY_KEYS.roomList(),
|
||||||
queryFn: () => deviceCommService.getRoomList(),
|
queryFn: () => deviceCommService.getRoomList(),
|
||||||
enabled,
|
enabled,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import * as userService from "@/services/user.service";
|
import * as userService from "@/services/user.service";
|
||||||
import type {
|
import type { UserProfile } from "@/types/user-profile";
|
||||||
UserProfile,
|
|
||||||
UpdateUserInfoRequest,
|
|
||||||
UpdateUserRoleRequest,
|
|
||||||
} from "@/types/user-profile";
|
|
||||||
|
|
||||||
const USER_QUERY_KEYS = {
|
const USER_QUERY_KEYS = {
|
||||||
all: ["users"] as const,
|
all: ["users"] as const,
|
||||||
|
|
@ -22,47 +18,3 @@ export function useGetUsersInfo(enabled = true) {
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
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(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,5 @@
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-scope {
|
|
||||||
font-family: "Be Vietnam Pro", "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
@ -14,12 +14,10 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
|
import { Route as AuthUserIndexRouteImport } from './routes/_auth/user/index'
|
||||||
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
|
import { Route as AuthRoomsIndexRouteImport } from './routes/_auth/rooms/index'
|
||||||
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
|
import { Route as AuthRoleIndexRouteImport } from './routes/_auth/role/index'
|
||||||
import { Route as AuthRemoteControlIndexRouteImport } from './routes/_auth/remote-control/index'
|
|
||||||
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
import { Route as AuthDeviceIndexRouteImport } from './routes/_auth/device/index'
|
||||||
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
|
||||||
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
|
import { Route as AuthCommandsIndexRouteImport } from './routes/_auth/commands/index'
|
||||||
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
|
import { Route as AuthBlacklistsIndexRouteImport } from './routes/_auth/blacklists/index'
|
||||||
import { Route as AuthAuditsIndexRouteImport } from './routes/_auth/audits/index'
|
|
||||||
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
|
import { Route as AuthAppsIndexRouteImport } from './routes/_auth/apps/index'
|
||||||
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
|
import { Route as AuthAgentIndexRouteImport } from './routes/_auth/agent/index'
|
||||||
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
|
import { Route as authLoginIndexRouteImport } from './routes/(auth)/login/index'
|
||||||
|
|
@ -28,12 +26,9 @@ import { Route as AuthRoomsRoomNameIndexRouteImport } from './routes/_auth/rooms
|
||||||
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
import { Route as AuthRoleCreateIndexRouteImport } from './routes/_auth/role/create/index'
|
||||||
import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_auth/profile/change-password/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 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 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 AuthUserChangePasswordUserNameIndexRouteImport } from './routes/_auth/user/change-password/$userName/index'
|
||||||
import { Route as AuthRoomsRoomNameFolderStatusIndexRouteImport } from './routes/_auth/rooms/$roomName/folder-status/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'
|
import { Route as AuthRoleIdEditIndexRouteImport } from './routes/_auth/role/$id/edit/index'
|
||||||
|
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
|
|
@ -60,11 +55,6 @@ const AuthRoleIndexRoute = AuthRoleIndexRouteImport.update({
|
||||||
path: '/role/',
|
path: '/role/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthRemoteControlIndexRoute = AuthRemoteControlIndexRouteImport.update({
|
|
||||||
id: '/remote-control/',
|
|
||||||
path: '/remote-control/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
|
const AuthDeviceIndexRoute = AuthDeviceIndexRouteImport.update({
|
||||||
id: '/device/',
|
id: '/device/',
|
||||||
path: '/device/',
|
path: '/device/',
|
||||||
|
|
@ -85,11 +75,6 @@ const AuthBlacklistsIndexRoute = AuthBlacklistsIndexRouteImport.update({
|
||||||
path: '/blacklists/',
|
path: '/blacklists/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthAuditsIndexRoute = AuthAuditsIndexRouteImport.update({
|
|
||||||
id: '/audits/',
|
|
||||||
path: '/audits/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
|
const AuthAppsIndexRoute = AuthAppsIndexRouteImport.update({
|
||||||
id: '/apps/',
|
id: '/apps/',
|
||||||
path: '/apps/',
|
path: '/apps/',
|
||||||
|
|
@ -132,22 +117,11 @@ const AuthProfileUserNameIndexRoute =
|
||||||
path: '/profile/$userName/',
|
path: '/profile/$userName/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const authOauthCallbackIndexRoute = authOauthCallbackIndexRouteImport.update({
|
|
||||||
id: '/(auth)/oauth/callback/',
|
|
||||||
path: '/oauth/callback/',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
|
||||||
id: '/user/role/$roleId/',
|
id: '/user/role/$roleId/',
|
||||||
path: '/user/role/$roleId/',
|
path: '/user/role/$roleId/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthUserEditUserNameIndexRoute =
|
|
||||||
AuthUserEditUserNameIndexRouteImport.update({
|
|
||||||
id: '/user/edit/$userName/',
|
|
||||||
path: '/user/edit/$userName/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthUserChangePasswordUserNameIndexRoute =
|
const AuthUserChangePasswordUserNameIndexRoute =
|
||||||
AuthUserChangePasswordUserNameIndexRouteImport.update({
|
AuthUserChangePasswordUserNameIndexRouteImport.update({
|
||||||
id: '/user/change-password/$userName/',
|
id: '/user/change-password/$userName/',
|
||||||
|
|
@ -160,12 +134,6 @@ const AuthRoomsRoomNameFolderStatusIndexRoute =
|
||||||
path: '/rooms/$roomName/folder-status/',
|
path: '/rooms/$roomName/folder-status/',
|
||||||
getParentRoute: () => AuthRoute,
|
getParentRoute: () => AuthRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthRoomsRoomNameConnectIndexRoute =
|
|
||||||
AuthRoomsRoomNameConnectIndexRouteImport.update({
|
|
||||||
id: '/rooms/$roomName/connect/',
|
|
||||||
path: '/rooms/$roomName/connect/',
|
|
||||||
getParentRoute: () => AuthRoute,
|
|
||||||
} as any)
|
|
||||||
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
const AuthRoleIdEditIndexRoute = AuthRoleIdEditIndexRouteImport.update({
|
||||||
id: '/role/$id/edit/',
|
id: '/role/$id/edit/',
|
||||||
path: '/role/$id/edit/',
|
path: '/role/$id/edit/',
|
||||||
|
|
@ -177,26 +145,21 @@ export interface FileRoutesByFullPath {
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof authLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthAppsIndexRoute
|
||||||
'/audits': typeof AuthAuditsIndexRoute
|
|
||||||
'/blacklists': typeof AuthBlacklistsIndexRoute
|
'/blacklists': typeof AuthBlacklistsIndexRoute
|
||||||
'/commands': typeof AuthCommandsIndexRoute
|
'/commands': typeof AuthCommandsIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/dashboard': typeof AuthDashboardIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/remote-control': typeof AuthRemoteControlIndexRoute
|
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
'/user': typeof AuthUserIndexRoute
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
||||||
'/user/create': typeof AuthUserCreateIndexRoute
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
|
|
||||||
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
|
|
||||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
|
|
@ -204,26 +167,21 @@ export interface FileRoutesByTo {
|
||||||
'/login': typeof authLoginIndexRoute
|
'/login': typeof authLoginIndexRoute
|
||||||
'/agent': typeof AuthAgentIndexRoute
|
'/agent': typeof AuthAgentIndexRoute
|
||||||
'/apps': typeof AuthAppsIndexRoute
|
'/apps': typeof AuthAppsIndexRoute
|
||||||
'/audits': typeof AuthAuditsIndexRoute
|
|
||||||
'/blacklists': typeof AuthBlacklistsIndexRoute
|
'/blacklists': typeof AuthBlacklistsIndexRoute
|
||||||
'/commands': typeof AuthCommandsIndexRoute
|
'/commands': typeof AuthCommandsIndexRoute
|
||||||
'/dashboard': typeof AuthDashboardIndexRoute
|
'/dashboard': typeof AuthDashboardIndexRoute
|
||||||
'/device': typeof AuthDeviceIndexRoute
|
'/device': typeof AuthDeviceIndexRoute
|
||||||
'/remote-control': typeof AuthRemoteControlIndexRoute
|
|
||||||
'/role': typeof AuthRoleIndexRoute
|
'/role': typeof AuthRoleIndexRoute
|
||||||
'/rooms': typeof AuthRoomsIndexRoute
|
'/rooms': typeof AuthRoomsIndexRoute
|
||||||
'/user': typeof AuthUserIndexRoute
|
'/user': typeof AuthUserIndexRoute
|
||||||
'/oauth/callback': typeof authOauthCallbackIndexRoute
|
|
||||||
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
'/profile/$userName': typeof AuthProfileUserNameIndexRoute
|
||||||
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
'/profile/change-password': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/role/create': typeof AuthRoleCreateIndexRoute
|
'/role/create': typeof AuthRoleCreateIndexRoute
|
||||||
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
'/rooms/$roomName': typeof AuthRoomsRoomNameIndexRoute
|
||||||
'/user/create': typeof AuthUserCreateIndexRoute
|
'/user/create': typeof AuthUserCreateIndexRoute
|
||||||
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
'/role/$id/edit': typeof AuthRoleIdEditIndexRoute
|
||||||
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute
|
|
||||||
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
'/rooms/$roomName/folder-status': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/user/change-password/$userName': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/user/edit/$userName': typeof AuthUserEditUserNameIndexRoute
|
|
||||||
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
'/user/role/$roleId': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
|
|
@ -233,26 +191,21 @@ export interface FileRoutesById {
|
||||||
'/(auth)/login/': typeof authLoginIndexRoute
|
'/(auth)/login/': typeof authLoginIndexRoute
|
||||||
'/_auth/agent/': typeof AuthAgentIndexRoute
|
'/_auth/agent/': typeof AuthAgentIndexRoute
|
||||||
'/_auth/apps/': typeof AuthAppsIndexRoute
|
'/_auth/apps/': typeof AuthAppsIndexRoute
|
||||||
'/_auth/audits/': typeof AuthAuditsIndexRoute
|
|
||||||
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
|
'/_auth/blacklists/': typeof AuthBlacklistsIndexRoute
|
||||||
'/_auth/commands/': typeof AuthCommandsIndexRoute
|
'/_auth/commands/': typeof AuthCommandsIndexRoute
|
||||||
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
|
||||||
'/_auth/device/': typeof AuthDeviceIndexRoute
|
'/_auth/device/': typeof AuthDeviceIndexRoute
|
||||||
'/_auth/remote-control/': typeof AuthRemoteControlIndexRoute
|
|
||||||
'/_auth/role/': typeof AuthRoleIndexRoute
|
'/_auth/role/': typeof AuthRoleIndexRoute
|
||||||
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
'/_auth/rooms/': typeof AuthRoomsIndexRoute
|
||||||
'/_auth/user/': typeof AuthUserIndexRoute
|
'/_auth/user/': typeof AuthUserIndexRoute
|
||||||
'/(auth)/oauth/callback/': typeof authOauthCallbackIndexRoute
|
|
||||||
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
'/_auth/profile/$userName/': typeof AuthProfileUserNameIndexRoute
|
||||||
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
'/_auth/profile/change-password/': typeof AuthProfileChangePasswordIndexRoute
|
||||||
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
'/_auth/role/create/': typeof AuthRoleCreateIndexRoute
|
||||||
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
|
'/_auth/rooms/$roomName/': typeof AuthRoomsRoomNameIndexRoute
|
||||||
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
|
'/_auth/user/create/': typeof AuthUserCreateIndexRoute
|
||||||
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
'/_auth/role/$id/edit/': typeof AuthRoleIdEditIndexRoute
|
||||||
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute
|
|
||||||
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
'/_auth/rooms/$roomName/folder-status/': typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
'/_auth/user/change-password/$userName/': typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
'/_auth/user/edit/$userName/': typeof AuthUserEditUserNameIndexRoute
|
|
||||||
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
'/_auth/user/role/$roleId/': typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
|
|
@ -262,26 +215,21 @@ export interface FileRouteTypes {
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/audits'
|
|
||||||
| '/blacklists'
|
| '/blacklists'
|
||||||
| '/commands'
|
| '/commands'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/remote-control'
|
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
| '/user'
|
| '/user'
|
||||||
| '/oauth/callback'
|
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
| '/rooms/$roomName'
|
| '/rooms/$roomName'
|
||||||
| '/user/create'
|
| '/user/create'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
| '/rooms/$roomName/connect'
|
|
||||||
| '/rooms/$roomName/folder-status'
|
| '/rooms/$roomName/folder-status'
|
||||||
| '/user/change-password/$userName'
|
| '/user/change-password/$userName'
|
||||||
| '/user/edit/$userName'
|
|
||||||
| '/user/role/$roleId'
|
| '/user/role/$roleId'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
|
|
@ -289,26 +237,21 @@ export interface FileRouteTypes {
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/agent'
|
| '/agent'
|
||||||
| '/apps'
|
| '/apps'
|
||||||
| '/audits'
|
|
||||||
| '/blacklists'
|
| '/blacklists'
|
||||||
| '/commands'
|
| '/commands'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/device'
|
| '/device'
|
||||||
| '/remote-control'
|
|
||||||
| '/role'
|
| '/role'
|
||||||
| '/rooms'
|
| '/rooms'
|
||||||
| '/user'
|
| '/user'
|
||||||
| '/oauth/callback'
|
|
||||||
| '/profile/$userName'
|
| '/profile/$userName'
|
||||||
| '/profile/change-password'
|
| '/profile/change-password'
|
||||||
| '/role/create'
|
| '/role/create'
|
||||||
| '/rooms/$roomName'
|
| '/rooms/$roomName'
|
||||||
| '/user/create'
|
| '/user/create'
|
||||||
| '/role/$id/edit'
|
| '/role/$id/edit'
|
||||||
| '/rooms/$roomName/connect'
|
|
||||||
| '/rooms/$roomName/folder-status'
|
| '/rooms/$roomName/folder-status'
|
||||||
| '/user/change-password/$userName'
|
| '/user/change-password/$userName'
|
||||||
| '/user/edit/$userName'
|
|
||||||
| '/user/role/$roleId'
|
| '/user/role/$roleId'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
|
|
@ -317,26 +260,21 @@ export interface FileRouteTypes {
|
||||||
| '/(auth)/login/'
|
| '/(auth)/login/'
|
||||||
| '/_auth/agent/'
|
| '/_auth/agent/'
|
||||||
| '/_auth/apps/'
|
| '/_auth/apps/'
|
||||||
| '/_auth/audits/'
|
|
||||||
| '/_auth/blacklists/'
|
| '/_auth/blacklists/'
|
||||||
| '/_auth/commands/'
|
| '/_auth/commands/'
|
||||||
| '/_auth/dashboard/'
|
| '/_auth/dashboard/'
|
||||||
| '/_auth/device/'
|
| '/_auth/device/'
|
||||||
| '/_auth/remote-control/'
|
|
||||||
| '/_auth/role/'
|
| '/_auth/role/'
|
||||||
| '/_auth/rooms/'
|
| '/_auth/rooms/'
|
||||||
| '/_auth/user/'
|
| '/_auth/user/'
|
||||||
| '/(auth)/oauth/callback/'
|
|
||||||
| '/_auth/profile/$userName/'
|
| '/_auth/profile/$userName/'
|
||||||
| '/_auth/profile/change-password/'
|
| '/_auth/profile/change-password/'
|
||||||
| '/_auth/role/create/'
|
| '/_auth/role/create/'
|
||||||
| '/_auth/rooms/$roomName/'
|
| '/_auth/rooms/$roomName/'
|
||||||
| '/_auth/user/create/'
|
| '/_auth/user/create/'
|
||||||
| '/_auth/role/$id/edit/'
|
| '/_auth/role/$id/edit/'
|
||||||
| '/_auth/rooms/$roomName/connect/'
|
|
||||||
| '/_auth/rooms/$roomName/folder-status/'
|
| '/_auth/rooms/$roomName/folder-status/'
|
||||||
| '/_auth/user/change-password/$userName/'
|
| '/_auth/user/change-password/$userName/'
|
||||||
| '/_auth/user/edit/$userName/'
|
|
||||||
| '/_auth/user/role/$roleId/'
|
| '/_auth/user/role/$roleId/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
|
|
@ -344,7 +282,6 @@ export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
authLoginIndexRoute: typeof authLoginIndexRoute
|
authLoginIndexRoute: typeof authLoginIndexRoute
|
||||||
authOauthCallbackIndexRoute: typeof authOauthCallbackIndexRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|
@ -384,13 +321,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthRoleIndexRouteImport
|
preLoaderRoute: typeof AuthRoleIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/remote-control/': {
|
|
||||||
id: '/_auth/remote-control/'
|
|
||||||
path: '/remote-control'
|
|
||||||
fullPath: '/remote-control'
|
|
||||||
preLoaderRoute: typeof AuthRemoteControlIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/device/': {
|
'/_auth/device/': {
|
||||||
id: '/_auth/device/'
|
id: '/_auth/device/'
|
||||||
path: '/device'
|
path: '/device'
|
||||||
|
|
@ -419,13 +349,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
|
preLoaderRoute: typeof AuthBlacklistsIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
parentRoute: typeof AuthRoute
|
||||||
}
|
}
|
||||||
'/_auth/audits/': {
|
|
||||||
id: '/_auth/audits/'
|
|
||||||
path: '/audits'
|
|
||||||
fullPath: '/audits'
|
|
||||||
preLoaderRoute: typeof AuthAuditsIndexRouteImport
|
|
||||||
parentRoute: typeof AuthRoute
|
|
||||||
}
|
|
||||||
'/_auth/apps/': {
|
'/_auth/apps/': {
|
||||||
id: '/_auth/apps/'
|
id: '/_auth/apps/'
|
||||||
path: '/apps'
|
path: '/apps'
|
||||||
|
|
@ -482,13 +405,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
preLoaderRoute: typeof AuthProfileUserNameIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
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/': {
|
'/_auth/user/role/$roleId/': {
|
||||||
id: '/_auth/user/role/$roleId/'
|
id: '/_auth/user/role/$roleId/'
|
||||||
path: '/user/role/$roleId'
|
path: '/user/role/$roleId'
|
||||||
|
|
@ -496,13 +412,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
|
preLoaderRoute: typeof AuthUserRoleRoleIdIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
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/': {
|
'/_auth/user/change-password/$userName/': {
|
||||||
id: '/_auth/user/change-password/$userName/'
|
id: '/_auth/user/change-password/$userName/'
|
||||||
path: '/user/change-password/$userName'
|
path: '/user/change-password/$userName'
|
||||||
|
|
@ -517,13 +426,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
|
preLoaderRoute: typeof AuthRoomsRoomNameFolderStatusIndexRouteImport
|
||||||
parentRoute: typeof AuthRoute
|
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/': {
|
'/_auth/role/$id/edit/': {
|
||||||
id: '/_auth/role/$id/edit/'
|
id: '/_auth/role/$id/edit/'
|
||||||
path: '/role/$id/edit'
|
path: '/role/$id/edit'
|
||||||
|
|
@ -537,12 +439,10 @@ declare module '@tanstack/react-router' {
|
||||||
interface AuthRouteChildren {
|
interface AuthRouteChildren {
|
||||||
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
|
AuthAgentIndexRoute: typeof AuthAgentIndexRoute
|
||||||
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
|
AuthAppsIndexRoute: typeof AuthAppsIndexRoute
|
||||||
AuthAuditsIndexRoute: typeof AuthAuditsIndexRoute
|
|
||||||
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
|
AuthBlacklistsIndexRoute: typeof AuthBlacklistsIndexRoute
|
||||||
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
|
AuthCommandsIndexRoute: typeof AuthCommandsIndexRoute
|
||||||
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
|
||||||
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
AuthDeviceIndexRoute: typeof AuthDeviceIndexRoute
|
||||||
AuthRemoteControlIndexRoute: typeof AuthRemoteControlIndexRoute
|
|
||||||
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
AuthRoleIndexRoute: typeof AuthRoleIndexRoute
|
||||||
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
AuthRoomsIndexRoute: typeof AuthRoomsIndexRoute
|
||||||
AuthUserIndexRoute: typeof AuthUserIndexRoute
|
AuthUserIndexRoute: typeof AuthUserIndexRoute
|
||||||
|
|
@ -552,22 +452,18 @@ interface AuthRouteChildren {
|
||||||
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
|
AuthRoomsRoomNameIndexRoute: typeof AuthRoomsRoomNameIndexRoute
|
||||||
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
|
AuthUserCreateIndexRoute: typeof AuthUserCreateIndexRoute
|
||||||
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
AuthRoleIdEditIndexRoute: typeof AuthRoleIdEditIndexRoute
|
||||||
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
|
|
||||||
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
|
||||||
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
|
||||||
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
|
|
||||||
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthRouteChildren: AuthRouteChildren = {
|
const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthAgentIndexRoute: AuthAgentIndexRoute,
|
AuthAgentIndexRoute: AuthAgentIndexRoute,
|
||||||
AuthAppsIndexRoute: AuthAppsIndexRoute,
|
AuthAppsIndexRoute: AuthAppsIndexRoute,
|
||||||
AuthAuditsIndexRoute: AuthAuditsIndexRoute,
|
|
||||||
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
|
AuthBlacklistsIndexRoute: AuthBlacklistsIndexRoute,
|
||||||
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
|
AuthCommandsIndexRoute: AuthCommandsIndexRoute,
|
||||||
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
|
||||||
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
AuthDeviceIndexRoute: AuthDeviceIndexRoute,
|
||||||
AuthRemoteControlIndexRoute: AuthRemoteControlIndexRoute,
|
|
||||||
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
AuthRoleIndexRoute: AuthRoleIndexRoute,
|
||||||
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
AuthRoomsIndexRoute: AuthRoomsIndexRoute,
|
||||||
AuthUserIndexRoute: AuthUserIndexRoute,
|
AuthUserIndexRoute: AuthUserIndexRoute,
|
||||||
|
|
@ -577,12 +473,10 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||||
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
|
AuthRoomsRoomNameIndexRoute: AuthRoomsRoomNameIndexRoute,
|
||||||
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
AuthUserCreateIndexRoute: AuthUserCreateIndexRoute,
|
||||||
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
AuthRoleIdEditIndexRoute: AuthRoleIdEditIndexRoute,
|
||||||
AuthRoomsRoomNameConnectIndexRoute: AuthRoomsRoomNameConnectIndexRoute,
|
|
||||||
AuthRoomsRoomNameFolderStatusIndexRoute:
|
AuthRoomsRoomNameFolderStatusIndexRoute:
|
||||||
AuthRoomsRoomNameFolderStatusIndexRoute,
|
AuthRoomsRoomNameFolderStatusIndexRoute,
|
||||||
AuthUserChangePasswordUserNameIndexRoute:
|
AuthUserChangePasswordUserNameIndexRoute:
|
||||||
AuthUserChangePasswordUserNameIndexRoute,
|
AuthUserChangePasswordUserNameIndexRoute,
|
||||||
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
|
|
||||||
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,7 +486,6 @@ const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
authLoginIndexRoute: authLoginIndexRoute,
|
authLoginIndexRoute: authLoginIndexRoute,
|
||||||
authOauthCallbackIndexRoute: authOauthCallbackIndexRoute,
|
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,10 +7,11 @@ import {
|
||||||
useUpdateAgent,
|
useUpdateAgent,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { Version } from "@/types/file";
|
import type { Version } from "@/types/file";
|
||||||
import { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
|
import { ErrorFetchingPage } from "@/components/pages/error-fetching-page";
|
||||||
import { agentColumns } from "@/components/columns/agent-column";
|
|
||||||
export const Route = createFileRoute("/_auth/agent/")({
|
export const Route = createFileRoute("/_auth/agent/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
head: () => ({ meta: [{ title: "Quản lý Agent" }] }),
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
|
|
@ -70,7 +71,26 @@ function AgentsPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cột bảng
|
// Cột bảng
|
||||||
|
const columns: ColumnDef<Version>[] = [
|
||||||
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
|
{ accessorKey: "fileName", header: "Tên file" },
|
||||||
|
{
|
||||||
|
accessorKey: "updatedAt",
|
||||||
|
header: "Thời gian cập nhật",
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "requestUpdateAt",
|
||||||
|
header: "Thời gian yêu cầu cập nhật",
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppManagerTemplate<Version>
|
<AppManagerTemplate<Version>
|
||||||
|
|
@ -78,7 +98,7 @@ function AgentsPage() {
|
||||||
description="Quản lý và theo dõi các phiên bản Agent"
|
description="Quản lý và theo dõi các phiên bản Agent"
|
||||||
data={versionList}
|
data={versionList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={agentColumns}
|
columns={columns}
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
onUpdate={handleUpdate}
|
onUpdate={handleUpdate}
|
||||||
updateLoading={updateMutation.isPending}
|
updateLoading={updateMutation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,14 @@ import {
|
||||||
useDeleteRequiredFile,
|
useDeleteRequiredFile,
|
||||||
useInstallMsi,
|
useInstallMsi,
|
||||||
useDownloadFiles,
|
useDownloadFiles,
|
||||||
useSendManifest,
|
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import type { Version } from "@/types/file";
|
import type { Version } from "@/types/file";
|
||||||
import { useMemo, useState } from "react";
|
import { Check, X } from "lucide-react";
|
||||||
import { createAppsColumns } from "@/components/columns/apps-column";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/apps/")({
|
export const Route = createFileRoute("/_auth/apps/")({
|
||||||
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
|
||||||
component: AppsComponent,
|
component: AppsComponent,
|
||||||
|
|
@ -50,12 +51,62 @@ function AppsComponent() {
|
||||||
|
|
||||||
const deleteRequiredFileMutation = useDeleteRequiredFile();
|
const deleteRequiredFileMutation = useDeleteRequiredFile();
|
||||||
|
|
||||||
const sendManifestMutation = useSendManifest();
|
// Cột bảng
|
||||||
|
const columns: ColumnDef<Version>[] = [
|
||||||
|
{ accessorKey: "version", header: "Phiên bản" },
|
||||||
|
{ accessorKey: "fileName", header: "Tên file" },
|
||||||
|
{
|
||||||
|
accessorKey: "updatedAt",
|
||||||
|
header: () => <div className="whitespace-normal max-w-xs">Thời gian cập nhật</div>,
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "requestUpdateAt",
|
||||||
|
header: () => <div className="whitespace-normal max-w-xs">Thời gian yêu cầu cài đặt/tải xuống</div>,
|
||||||
|
cell: ({ getValue }) =>
|
||||||
|
getValue()
|
||||||
|
? new Date(getValue() as string).toLocaleString("vi-VN")
|
||||||
|
: "N/A",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "required",
|
||||||
|
header: () => <div className="whitespace-normal max-w-xs">Đã thêm vào danh sách</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isRequired = row.original.isRequired;
|
||||||
|
return isRequired ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="text-sm text-green-600">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={installMutation.isPending}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => createAppsColumns(installMutation.isPending),
|
|
||||||
[installMutation.isPending]
|
|
||||||
);
|
|
||||||
// Upload file MSI
|
// Upload file MSI
|
||||||
const handleUpload = async (
|
const handleUpload = async (
|
||||||
fd: FormData,
|
fd: FormData,
|
||||||
|
|
@ -140,10 +191,11 @@ function AppsComponent() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
for (const row of selectedRows) {
|
||||||
|
const { id } = row.original;
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
}
|
||||||
toast.success("Xóa phần mềm thành công!");
|
toast.success("Xóa phần mềm thành công!");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("Xóa phần mềm thất bại!");
|
toast.error("Xóa phần mềm thất bại!");
|
||||||
|
|
@ -154,15 +206,12 @@ function AppsComponent() {
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
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 {
|
try {
|
||||||
await deleteRequiredFileMutation.mutateAsync({ MsiFileIds });
|
for (const row of selectedRows) {
|
||||||
|
const { id } = row.original;
|
||||||
|
await deleteRequiredFileMutation.mutateAsync(id);
|
||||||
|
}
|
||||||
toast.success("Xóa file khỏi danh sách thành công!");
|
toast.success("Xóa file khỏi danh sách thành công!");
|
||||||
if (table) {
|
if (table) {
|
||||||
table.setRowSelection({});
|
table.setRowSelection({});
|
||||||
|
|
@ -177,10 +226,12 @@ function AppsComponent() {
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().rows;
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteMutation.mutateAsync({ MsiFileIds });
|
for (const row of selectedRows) {
|
||||||
|
const { id } = row.original;
|
||||||
|
await deleteMutation.mutateAsync(id);
|
||||||
|
}
|
||||||
toast.success("Xóa phần mềm từ server thành công!");
|
toast.success("Xóa phần mềm từ server thành công!");
|
||||||
if (table) {
|
if (table) {
|
||||||
table.setRowSelection({});
|
table.setRowSelection({});
|
||||||
|
|
@ -191,21 +242,6 @@ function AppsComponent() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendManifest = async (targets: string[]) => {
|
|
||||||
// targets ignored for now — API sendall broadcasts to all devices via MQTT
|
|
||||||
// TODO: use targets when per-room/per-device manifest API is available
|
|
||||||
try {
|
|
||||||
await sendManifestMutation.mutateAsync();
|
|
||||||
toast.success(
|
|
||||||
targets.length > 0
|
|
||||||
? `Đã gửi manifest cho ${targets.length} mục!`
|
|
||||||
: "Đã gửi manifest đến tất cả thiết bị!"
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("Gửi manifest thất bại!");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToRequired = async () => {
|
const handleAddToRequired = async () => {
|
||||||
if (!table) {
|
if (!table) {
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
|
|
@ -250,8 +286,6 @@ function AppsComponent() {
|
||||||
onDeleteFromServer={handleDeleteFromServer}
|
onDeleteFromServer={handleDeleteFromServer}
|
||||||
onDeleteFromRequired={handleDeleteFromRequiredList}
|
onDeleteFromRequired={handleDeleteFromRequiredList}
|
||||||
onAddToRequired={handleAddToRequired}
|
onAddToRequired={handleAddToRequired}
|
||||||
onSendManifest={handleSendManifest}
|
|
||||||
sendManifestLoading={sendManifestMutation.isPending}
|
|
||||||
updateLoading={installMutation.isPending}
|
updateLoading={installMutation.isPending}
|
||||||
downloadLoading={downloadMutation.isPending}
|
downloadLoading={downloadMutation.isPending}
|
||||||
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
|
deleteLoading={deleteMutation.isPending || deleteRequiredFileMutation.isPending}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +1,6 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { CommandSubmitTemplate } from "@/template/command-submit-template";
|
||||||
CommandSubmitTemplate,
|
|
||||||
type SendCommandOptions,
|
|
||||||
} from "@/template/command-submit-template";
|
|
||||||
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
import { CommandRegistryForm, type CommandRegistryFormData } from "@/components/forms/command-registry-form";
|
||||||
import {
|
import {
|
||||||
useGetCommandList,
|
useGetCommandList,
|
||||||
|
|
@ -14,6 +11,7 @@ import {
|
||||||
useSendCommand,
|
useSendCommand,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||||
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
import { Check, X, Edit2, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
|
|
@ -70,18 +68,6 @@ function CommandPage() {
|
||||||
const deleteCommandMutation = useDeleteCommand();
|
const deleteCommandMutation = useDeleteCommand();
|
||||||
const sendCommandMutation = useSendCommand();
|
const sendCommandMutation = useSendCommand();
|
||||||
|
|
||||||
const formInitialData = selectedCommand
|
|
||||||
? {
|
|
||||||
commandName: selectedCommand.commandName,
|
|
||||||
commandType: selectedCommand.commandType,
|
|
||||||
description: selectedCommand.description,
|
|
||||||
commandContent: selectedCommand.commandContent,
|
|
||||||
qos: selectedCommand.qoS,
|
|
||||||
isRetained: selectedCommand.isRetained,
|
|
||||||
ttlMinutes: selectedCommand.ttlMinutes ?? -1,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// Columns for command table
|
// Columns for command table
|
||||||
const columns: ColumnDef<CommandRegistry>[] = [
|
const columns: ColumnDef<CommandRegistry>[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -107,7 +93,6 @@ function CommandPage() {
|
||||||
2: "SHUTDOWN",
|
2: "SHUTDOWN",
|
||||||
3: "TASKKILL",
|
3: "TASKKILL",
|
||||||
4: "BLOCK",
|
4: "BLOCK",
|
||||||
5: "RESET",
|
|
||||||
};
|
};
|
||||||
return <span>{typeMap[type] || "UNKNOWN"}</span>;
|
return <span>{typeMap[type] || "UNKNOWN"}</span>;
|
||||||
},
|
},
|
||||||
|
|
@ -241,10 +226,7 @@ function CommandPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle execute commands from list
|
// Handle execute commands from list
|
||||||
const handleExecuteSelected = async (
|
const handleExecuteSelected = async (targets: string[]) => {
|
||||||
targets: string[],
|
|
||||||
options?: SendCommandOptions
|
|
||||||
) => {
|
|
||||||
if (!table) {
|
if (!table) {
|
||||||
toast.error("Không thể lấy thông tin bảng!");
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
return;
|
return;
|
||||||
|
|
@ -264,8 +246,6 @@ function CommandPage() {
|
||||||
Command: row.original.commandContent,
|
Command: row.original.commandContent,
|
||||||
QoS: row.original.qoS,
|
QoS: row.original.qoS,
|
||||||
IsRetained: row.original.isRetained,
|
IsRetained: row.original.isRetained,
|
||||||
TtlMinutes: options?.ttlMinutes,
|
|
||||||
SendTime: options?.sendTime,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await sendCommandMutation.mutateAsync({
|
await sendCommandMutation.mutateAsync({
|
||||||
|
|
@ -284,11 +264,7 @@ function CommandPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle execute custom command
|
// Handle execute custom command
|
||||||
const handleExecuteCustom = async (
|
const handleExecuteCustom = async (targets: string[], commandData: ShellCommandData) => {
|
||||||
targets: string[],
|
|
||||||
commandData: ShellCommandData,
|
|
||||||
options?: SendCommandOptions
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
// API expects PascalCase directly
|
// API expects PascalCase directly
|
||||||
|
|
@ -296,8 +272,6 @@ function CommandPage() {
|
||||||
Command: commandData.command,
|
Command: commandData.command,
|
||||||
QoS: commandData.qos,
|
QoS: commandData.qos,
|
||||||
IsRetained: commandData.isRetained,
|
IsRetained: commandData.isRetained,
|
||||||
TtlMinutes: options?.ttlMinutes,
|
|
||||||
SendTime: options?.sendTime,
|
|
||||||
};
|
};
|
||||||
await sendCommandMutation.mutateAsync({
|
await sendCommandMutation.mutateAsync({
|
||||||
roomName: target,
|
roomName: target,
|
||||||
|
|
@ -330,7 +304,7 @@ function CommandPage() {
|
||||||
<CommandRegistryForm
|
<CommandRegistryForm
|
||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
closeDialog={() => setIsDialogOpen(false)}
|
closeDialog={() => setIsDialogOpen(false)}
|
||||||
initialData={formInitialData}
|
initialData={selectedCommand || undefined}
|
||||||
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
title={selectedCommand ? "Sửa Lệnh" : "Thêm Lệnh Mới"}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,15 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
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/')({
|
export const Route = createFileRoute('/_auth/dashboard/')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
head: () => ({ meta: [{ title: 'Dashboard' }] }),
|
head: () => ({ meta: [{ title: 'Dashboard' }] }),
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
context.breadcrumbs = [
|
context.breadcrumbs = [
|
||||||
{ title: "Dashboard", path: "#" },
|
{ title: "Dashboard", path: "/_auth/dashboard/" },
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const summaryQuery = useGetDashboardSummary();
|
return <div>Hello "/(auth)/dashboard/"!</div>
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,149 +0,0 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { LoaderCircle, Monitor, X, Maximize2 } from "lucide-react";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { getRemoteDesktopUrl } from "@/services/remote-control.service";
|
|
||||||
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/remote-control/")({
|
|
||||||
head: () => ({ meta: [{ title: "Điều khiển trực tiếp" }] }),
|
|
||||||
component: RemoteControlPage,
|
|
||||||
loader: async ({ context }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Điều khiển từ xa", path: "/_auth/remote-control/" },
|
|
||||||
{ title: "Điều khiển trực tiếp", path: "/_auth/remote-control/" },
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function RemoteControlPage() {
|
|
||||||
const [nodeId, setNodeId] = useState("");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const [showRemote, setShowRemote] = useState(false);
|
|
||||||
const [proxyUrl, setProxyUrl] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const connectMutation = useMutation({
|
|
||||||
mutationFn: async (nodeIdValue: string) => {
|
|
||||||
// Gọi API để lấy URL remote desktop
|
|
||||||
const response = await getRemoteDesktopUrl(nodeIdValue);
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
console.log("[RemoteControl] URL:", data.url);
|
|
||||||
setProxyUrl(data.url);
|
|
||||||
setShowRemote(true);
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
console.error("[RemoteControl] Error:", error);
|
|
||||||
setErrorMessage(error?.response?.data?.message || "Lỗi không xác định khi kết nối remote.");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleConnect = (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const trimmedNodeId = nodeId.trim();
|
|
||||||
if (!trimmedNodeId) {
|
|
||||||
setErrorMessage("Vui lòng nhập nodeID.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setErrorMessage(null);
|
|
||||||
connectMutation.mutate(trimmedNodeId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setShowRemote(false);
|
|
||||||
setProxyUrl(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFullscreen = () => {
|
|
||||||
const iframe = document.getElementById("mesh-iframe") as HTMLIFrameElement;
|
|
||||||
if (iframe?.requestFullscreen) {
|
|
||||||
iframe.requestFullscreen();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-4xl space-y-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
|
||||||
<Monitor className="h-5 w-5" />
|
|
||||||
Điều khiển trực tiếp
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Nhập nodeID thiết bị 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,9 +0,0 @@
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/rooms/$roomName/connect/')({
|
|
||||||
component: RouteComponent,
|
|
||||||
})
|
|
||||||
|
|
||||||
function RouteComponent() {
|
|
||||||
return <div>Hello "/_auth/rooms/$roomName/connect/"!</div>
|
|
||||||
}
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useGetClientFolderStatus } from "@/hooks/queries";
|
import { useGetClientFolderStatus } from "@/hooks/queries";
|
||||||
import type { ClientFolderStatus, ExtraFile, MissingFile } from "@/types/folder";
|
import type { ClientFolderStatus } from "@/types/folder";
|
||||||
import FolderStatusTemplate from "@/template/folder-status-template";
|
import FolderStatusTemplate from "@/template/folder-status-template";
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
|
|
@ -36,46 +36,8 @@ function RouteComponent() {
|
||||||
roomName as string,
|
roomName as string,
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortedFolderStatusList = useMemo(() => {
|
|
||||||
return [...(folderStatusList ?? [])].sort((a, b) => {
|
|
||||||
const aRoom = (a as ClientFolderStatus & { roomName?: string }).roomName;
|
|
||||||
const bRoom = (b as ClientFolderStatus & { roomName?: string }).roomName;
|
|
||||||
|
|
||||||
if (aRoom || bRoom) {
|
|
||||||
return (aRoom ?? "").localeCompare(bRoom ?? "", "vi", {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (a.deviceId ?? "").localeCompare(b.deviceId ?? "", "vi", {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [folderStatusList]);
|
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
const columnHelper = createColumnHelper<ClientFolderStatus>();
|
||||||
|
|
||||||
const renderFileList = (files?: MissingFile[] | ExtraFile[]) => {
|
|
||||||
if (!files || files.length === 0) {
|
|
||||||
return <span className="text-muted-foreground">-</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-h-32 overflow-auto space-y-1">
|
|
||||||
{files.map((file) => (
|
|
||||||
<div key={`${file.folderPath}/${file.fileName}`} className="text-xs">
|
|
||||||
<div className="font-mono break-all">{file.fileName}</div>
|
|
||||||
<div className="text-muted-foreground break-all">
|
|
||||||
{file.folderPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
columnHelper.accessor("deviceId", {
|
columnHelper.accessor("deviceId", {
|
||||||
|
|
@ -84,13 +46,14 @@ function RouteComponent() {
|
||||||
}),
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: "missing",
|
id: "missing",
|
||||||
header: "File thiếu",
|
header: "Số lượng file thiếu",
|
||||||
cell: (info) => renderFileList(info.row.original.missingFiles),
|
cell: (info) =>
|
||||||
|
(info.row.original.missingFiles?.length ?? 0).toString(),
|
||||||
}),
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: "extra",
|
id: "extra",
|
||||||
header: "File thừa",
|
header: "Số lượng file thừa",
|
||||||
cell: (info) => renderFileList(info.row.original.extraFiles),
|
cell: (info) => (info.row.original.extraFiles?.length ?? 0).toString(),
|
||||||
}),
|
}),
|
||||||
columnHelper.display({
|
columnHelper.display({
|
||||||
id: "current",
|
id: "current",
|
||||||
|
|
@ -117,7 +80,7 @@ function RouteComponent() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: sortedFolderStatusList,
|
data: folderStatusList ?? [],
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
@ -125,7 +88,7 @@ function RouteComponent() {
|
||||||
return (
|
return (
|
||||||
<FolderStatusTemplate
|
<FolderStatusTemplate
|
||||||
roomName={roomName as string}
|
roomName={roomName as string}
|
||||||
data={sortedFolderStatusList}
|
data={folderStatusList}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onBack={() =>
|
onBack={() =>
|
||||||
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
navigate({ to: "/rooms/$roomName/", params: { roomName } } as any)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,14 @@
|
||||||
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
import { useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
import { LayoutGrid, TableIcon, Monitor, FolderCheck } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { useGetDeviceFromRoom } from "@/hooks/queries";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { useGetDeviceFromRoom, useGetRoomList } from "@/hooks/queries";
|
|
||||||
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
import { useDeviceEvents } from "@/hooks/useDeviceEvents";
|
||||||
import { DeviceGrid } from "@/components/grids/device-grid";
|
import { DeviceGrid } from "@/components/grids/device-grid";
|
||||||
import { DeviceGridCompact } from "@/components/grids/device-grid-compact";
|
|
||||||
import { DeviceTable } from "@/components/tables/device-table";
|
import { DeviceTable } from "@/components/tables/device-table";
|
||||||
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
import { useMachineNumber } from "@/hooks/useMachineNumber";
|
||||||
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
import { CommandActionButtons } from "@/components/buttons/command-action-buttons";
|
||||||
import { DeviceActionBar } from "@/components/bars/device-action-bar";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||||
head: ({ params }) => ({
|
head: ({ params }) => ({
|
||||||
|
|
@ -34,174 +25,69 @@ export const Route = createFileRoute("/_auth/rooms/$roomName/")({
|
||||||
|
|
||||||
function RoomDetailPage() {
|
function RoomDetailPage() {
|
||||||
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
const { roomName } = useParams({ from: "/_auth/rooms/$roomName/" });
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "table" | "map">("map");
|
const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
|
||||||
const [statusFilter, setStatusFilter] = useState<"all" | "on" | "off">("all");
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
const [lastSelectedIndex, setLastSelectedIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// SSE real-time updates
|
// SSE real-time updates
|
||||||
useDeviceEvents(roomName);
|
useDeviceEvents(roomName);
|
||||||
|
|
||||||
// Folder status from SS
|
// Folder status from SS
|
||||||
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
|
const { data: devices = [] } = useGetDeviceFromRoom(roomName);
|
||||||
const { data: roomData = [] } = useGetRoomList();
|
|
||||||
|
|
||||||
const parseMachineNumber = useMachineNumber();
|
const parseMachineNumber = useMachineNumber();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const sortedDevices = useMemo(() => {
|
const sortedDevices = [...devices].sort((a, b) => {
|
||||||
return [...devices].sort((a, b) => {
|
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
||||||
return parseMachineNumber(a.id) - parseMachineNumber(b.id);
|
});
|
||||||
});
|
|
||||||
}, [devices, parseMachineNumber]);
|
|
||||||
|
|
||||||
const currentRoom = roomData.find((room) => room.name === roomName);
|
|
||||||
const totalSeats = currentRoom?.numberOfDevices;
|
|
||||||
const deviceCount = sortedDevices.length;
|
|
||||||
const offlineCount = sortedDevices.filter((device) => device.isOffline).length;
|
|
||||||
const onlineCount = Math.max(0, deviceCount - offlineCount);
|
|
||||||
|
|
||||||
const mapDisabled = deviceCount > 200;
|
|
||||||
const forceTable = deviceCount > 2000;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = setTimeout(() => {
|
|
||||||
setDebouncedSearch(searchInput.trim());
|
|
||||||
}, 300);
|
|
||||||
return () => clearTimeout(handler);
|
|
||||||
}, [searchInput]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedIds([]);
|
|
||||||
setLastSelectedIndex(null);
|
|
||||||
}, [roomName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (forceTable && viewMode !== "table") {
|
|
||||||
setViewMode("table");
|
|
||||||
}
|
|
||||||
}, [forceTable, viewMode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mapDisabled && viewMode === "map") {
|
|
||||||
setViewMode("grid");
|
|
||||||
}
|
|
||||||
}, [mapDisabled, viewMode]);
|
|
||||||
|
|
||||||
const filteredDevices = useMemo(() => {
|
|
||||||
const query = debouncedSearch.toLowerCase();
|
|
||||||
return sortedDevices.filter((device) => {
|
|
||||||
if (statusFilter === "on" && device.isOffline) return false;
|
|
||||||
if (statusFilter === "off" && !device.isOffline) return false;
|
|
||||||
|
|
||||||
if (!query) return true;
|
|
||||||
|
|
||||||
const ipAddress = device.networkInfos?.[0]?.ipAddress ?? "";
|
|
||||||
const macAddress = device.networkInfos?.[0]?.macAddress ?? "";
|
|
||||||
const id = device.id ?? "";
|
|
||||||
const haystack = `${id} ${ipAddress} ${macAddress}`.toLowerCase();
|
|
||||||
return haystack.includes(query);
|
|
||||||
});
|
|
||||||
}, [sortedDevices, statusFilter, debouncedSearch]);
|
|
||||||
|
|
||||||
const filteredDeviceIds = useMemo(
|
|
||||||
() => filteredDevices.map((device) => device.id),
|
|
||||||
[filteredDevices]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
|
||||||
const selectedDevices = useMemo(
|
|
||||||
() => devices.filter((device) => selectedSet.has(device.id)),
|
|
||||||
[devices, selectedSet]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedIds((prev) => prev.filter((id) => devices.some((d) => d.id === id)));
|
|
||||||
}, [devices]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lastSelectedIndex !== null && lastSelectedIndex >= filteredDeviceIds.length) {
|
|
||||||
setLastSelectedIndex(null);
|
|
||||||
}
|
|
||||||
}, [filteredDeviceIds.length, lastSelectedIndex]);
|
|
||||||
|
|
||||||
const handleSelectDevice = (
|
|
||||||
deviceId: string,
|
|
||||||
index: number,
|
|
||||||
event: MouseEvent<HTMLElement>
|
|
||||||
) => {
|
|
||||||
const isShift = event.shiftKey;
|
|
||||||
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (isShift && lastSelectedIndex !== null) {
|
|
||||||
const start = Math.min(lastSelectedIndex, index);
|
|
||||||
const end = Math.max(lastSelectedIndex, index);
|
|
||||||
const rangeIds = filteredDeviceIds.slice(start, end + 1);
|
|
||||||
rangeIds.forEach((id) => next.add(id));
|
|
||||||
return Array.from(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.has(deviceId)) {
|
|
||||||
next.delete(deviceId);
|
|
||||||
} else {
|
|
||||||
next.add(deviceId);
|
|
||||||
}
|
|
||||||
return Array.from(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
setLastSelectedIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleAll = (checked: boolean) => {
|
|
||||||
setSelectedIds(checked ? filteredDeviceIds : []);
|
|
||||||
setLastSelectedIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearSelection = () => {
|
|
||||||
setSelectedIds([]);
|
|
||||||
setLastSelectedIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const chipOptions = [
|
|
||||||
{ key: "all" as const, label: `Tất cả (${deviceCount})` },
|
|
||||||
{ key: "on" as const, label: `On (${onlineCount})` },
|
|
||||||
{ key: "off" as const, label: `Off (${offlineCount})` },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full px-6">
|
<div className="w-full px-6 space-y-6">
|
||||||
<div className="space-y-6">
|
<Card className="shadow-sm">
|
||||||
<Card className="shadow-sm">
|
<CardHeader className="bg-muted/50 space-y-4">
|
||||||
<CardHeader className="bg-muted/50 space-y-3 pb-3">
|
{/* Hàng 1: Thông tin phòng và controls */}
|
||||||
{/* Row 1: Title + stats */}
|
<div className="flex items-center justify-between w-full gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<Monitor className="h-5 w-5" />
|
||||||
<Monitor className="h-5 w-5" />
|
<CardTitle>Danh sách thiết bị phòng {roomName}</CardTitle>
|
||||||
<CardTitle>Phòng {roomName}</CardTitle>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2 bg-background rounded-lg p-1 border shrink-0">
|
||||||
<Badge variant="outline" className="text-[11px] text-teal-700 border-teal-200 bg-teal-50">
|
<Button
|
||||||
On {onlineCount}
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||||
</Badge>
|
size="sm"
|
||||||
<Badge variant="outline" className="text-[11px] text-slate-600 border-slate-200 bg-slate-50">
|
onClick={() => setViewMode("grid")}
|
||||||
Off {offlineCount}
|
className="flex items-center gap-2"
|
||||||
</Badge>
|
>
|
||||||
<Badge variant="secondary" className="text-[11px]">
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Tổng {deviceCount}
|
Sơ đồ
|
||||||
</Badge>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
variant={viewMode === "table" ? "default" : "ghost"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setViewMode("table")}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<TableIcon className="h-4 w-4" />
|
||||||
|
Bảng
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Command buttons + folder button cùng hàng */}
|
{/* Hàng 2: Thực thi lệnh */}
|
||||||
|
<div className="flex items-center justify-between w-full gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
Thực thi lệnh
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 justify-end">
|
||||||
|
{/* Command Action Buttons */}
|
||||||
{devices.length > 0 && (
|
{devices.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<>
|
||||||
<CommandActionButtons roomName={roomName} />
|
<CommandActionButtons roomName={roomName} />
|
||||||
<div className="h-5 w-px bg-border shrink-0" />
|
|
||||||
|
<div className="h-8 w-px bg-border" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate({
|
navigate({
|
||||||
|
|
@ -216,158 +102,33 @@ function RoomDetailPage() {
|
||||||
<FolderCheck className="h-4 w-4" />
|
<FolderCheck className="h-4 w-4" />
|
||||||
Kiểm tra thư mục Setup
|
Kiểm tra thư mục Setup
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
{/* Row 3: View toggle + search + filter */}
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{devices.length === 0 ? (
|
||||||
{/* View mode */}
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<div className="flex items-center gap-1 rounded-lg border bg-background p-0.5">
|
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
<Button
|
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
||||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
<p className="text-muted-foreground text-center max-w-sm">
|
||||||
size="sm"
|
Phòng này chưa có thiết bị nào được kết nối.
|
||||||
onClick={() => setViewMode("grid")}
|
</p>
|
||||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
</div>
|
||||||
disabled={forceTable}
|
) : viewMode === "grid" ? (
|
||||||
>
|
<DeviceGrid
|
||||||
<LayoutGrid className="h-3.5 w-3.5" />
|
devices={sortedDevices}
|
||||||
Lưới
|
/>
|
||||||
</Button>
|
) : (
|
||||||
|
<DeviceTable
|
||||||
|
devices={sortedDevices}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "table" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode("table")}
|
|
||||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
|
||||||
>
|
|
||||||
<TableIcon className="h-3.5 w-3.5" />
|
|
||||||
Bảng
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{mapDisabled || forceTable ? (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Monitor className="h-3.5 w-3.5" />
|
|
||||||
Sơ đồ
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Sơ đồ chỉ hỗ trợ phòng <= 200 thiết bị.
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={viewMode === "map" ? "default" : "ghost"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setViewMode("map")}
|
|
||||||
className="h-7 gap-1.5 px-2.5 text-xs"
|
|
||||||
>
|
|
||||||
<Monitor className="h-3.5 w-3.5" />
|
|
||||||
Sơ đồ
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<Input
|
|
||||||
value={searchInput}
|
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
|
||||||
placeholder="Tìm theo số máy, IP hoặc mã thiết bị"
|
|
||||||
className="h-8 w-56 shrink-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Status filter */}
|
|
||||||
<div className="flex items-center gap-1 ml-auto">
|
|
||||||
{chipOptions.map((chip) => (
|
|
||||||
<Button
|
|
||||||
key={chip.key}
|
|
||||||
variant={statusFilter === chip.key ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={() => setStatusFilter(chip.key)}
|
|
||||||
>
|
|
||||||
{chip.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{devices.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Monitor className="h-12 w-12 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">Không có thiết bị</h3>
|
|
||||||
<p className="text-muted-foreground text-center max-w-sm">
|
|
||||||
Phòng này chưa có thiết bị nào được kết nối.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
{forceTable && (
|
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
|
||||||
Phòng này có {deviceCount} thiết bị. Chỉ hiển thị chế độ
|
|
||||||
Bảng để đảm bảo hiệu năng.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!forceTable && viewMode === "grid" && deviceCount > 500 && (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
|
||||||
Phòng này có nhiều thiết bị. Bạn có thể chuyển sang chế độ
|
|
||||||
Bảng để thao tác nhanh hơn.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredDevices.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Monitor className="h-10 w-10 text-muted-foreground mb-3" />
|
|
||||||
<h3 className="text-base font-semibold mb-1">
|
|
||||||
Không có thiết bị phù hợp
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-center max-w-sm">
|
|
||||||
Hãy thử thay đổi từ khóa hoặc bộ lọc trạng thái.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : forceTable || viewMode === "table" ? (
|
|
||||||
<DeviceTable
|
|
||||||
devices={filteredDevices}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onToggleDevice={handleSelectDevice}
|
|
||||||
onToggleAll={handleToggleAll}
|
|
||||||
/>
|
|
||||||
) : viewMode === "map" ? (
|
|
||||||
<DeviceGrid
|
|
||||||
devices={filteredDevices}
|
|
||||||
totalSeats={totalSeats}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSelectDevice={handleSelectDevice}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DeviceGridCompact
|
|
||||||
devices={filteredDevices}
|
|
||||||
selectedIds={selectedIds}
|
|
||||||
onSelectDevice={handleSelectDevice}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<DeviceActionBar
|
|
||||||
roomName={roomName}
|
|
||||||
selectedDevices={selectedDevices}
|
|
||||||
onClearSelection={handleClearSelection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import { LoaderCircle } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
|
export const Route = createFileRoute("/_auth/user/change-password/$userName/")({
|
||||||
head: () => ({
|
|
||||||
meta: [{ title: "Thay đổi mật khẩu" }],
|
|
||||||
}),
|
|
||||||
component: AdminChangePasswordComponent,
|
component: AdminChangePasswordComponent,
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
context.breadcrumbs = [
|
context.breadcrumbs = [
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ import { UserPlus, ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/user/create/")({
|
export const Route = createFileRoute("/_auth/user/create/")({
|
||||||
head: () => ({
|
|
||||||
meta: [{ title: "Tạo người dùng mới" }],
|
|
||||||
}),
|
|
||||||
component: CreateUserComponent,
|
component: CreateUserComponent,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
context.breadcrumbs = [
|
context.breadcrumbs = [
|
||||||
|
|
@ -62,8 +59,7 @@ function CreateUserComponent() {
|
||||||
if (!formData.userName) {
|
if (!formData.userName) {
|
||||||
newErrors.userName = "Tên đăng nhập không được để trống";
|
newErrors.userName = "Tên đăng nhập không được để trống";
|
||||||
} else if (!validateUserName(formData.userName)) {
|
} else if (!validateUserName(formData.userName)) {
|
||||||
newErrors.userName =
|
newErrors.userName = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
|
||||||
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate name
|
// Validate name
|
||||||
|
|
@ -110,8 +106,7 @@ function CreateUserComponent() {
|
||||||
toast.success("Tạo tài khoản thành công!");
|
toast.success("Tạo tài khoản thành công!");
|
||||||
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
|
navigate({ to: "/dashboard" }); // TODO: Navigate to user list page when it exists
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage =
|
const errorMessage = error.response?.data?.message || "Tạo tài khoản thất bại!";
|
||||||
error.response?.data?.message || "Tạo tài khoản thất bại!";
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -133,14 +128,15 @@ function CreateUserComponent() {
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">Tạo người dùng mới</h1>
|
||||||
Tạo người dùng mới
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Thêm tài khoản người dùng mới vào hệ thống
|
Thêm tài khoản người dùng mới vào hệ thống
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ to: "/user" })}
|
||||||
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Quay lại
|
Quay lại
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -168,9 +164,7 @@ function CreateUserComponent() {
|
||||||
<Input
|
<Input
|
||||||
id="userName"
|
id="userName"
|
||||||
value={formData.userName}
|
value={formData.userName}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleInputChange("userName", e.target.value)}
|
||||||
handleInputChange("userName", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
|
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
|
@ -208,9 +202,7 @@ function CreateUserComponent() {
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||||
handleInputChange("password", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
|
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
|
|
@ -228,17 +220,13 @@ function CreateUserComponent() {
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.confirmPassword}
|
value={formData.confirmPassword}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleInputChange("confirmPassword", e.target.value)}
|
||||||
handleInputChange("confirmPassword", e.target.value)
|
|
||||||
}
|
|
||||||
placeholder="Nhập lại mật khẩu"
|
placeholder="Nhập lại mật khẩu"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="h-10"
|
className="h-10"
|
||||||
/>
|
/>
|
||||||
{errors.confirmPassword && (
|
{errors.confirmPassword && (
|
||||||
<p className="text-sm text-destructive">
|
<p className="text-sm text-destructive">{errors.confirmPassword}</p>
|
||||||
{errors.confirmPassword}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,361 +0,0 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
useGetRoleList,
|
|
||||||
useGetRoomList,
|
|
||||||
useGetUsersInfo,
|
|
||||||
useUpdateUserInfo,
|
|
||||||
useUpdateUserRole,
|
|
||||||
} from "@/hooks/queries";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
|
||||||
import { ArrowLeft, Save } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { UserProfile } from "@/types/user-profile";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/user/edit/$userName/")({
|
|
||||||
head: () => ({
|
|
||||||
meta: [{ title: "Chỉnh sửa người dùng" }],
|
|
||||||
}),
|
|
||||||
component: EditUserComponent,
|
|
||||||
loader: async ({ context, params }) => {
|
|
||||||
context.breadcrumbs = [
|
|
||||||
{ title: "Quản lý người dùng", path: "/user" },
|
|
||||||
{
|
|
||||||
title: `Chỉnh sửa thông tin người dùng ${params.userName}`,
|
|
||||||
path: `/user/edit/${params.userName}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function EditUserComponent() {
|
|
||||||
const { userName } = Route.useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { data: users = [], isLoading } = useGetUsersInfo();
|
|
||||||
const { data: roomData = [], isLoading: roomsLoading } = useGetRoomList();
|
|
||||||
const { data: roles = [], isLoading: rolesLoading } = useGetRoleList();
|
|
||||||
const updateUserInfoMutation = useUpdateUserInfo();
|
|
||||||
const updateUserRoleMutation = useUpdateUserRole();
|
|
||||||
|
|
||||||
const user = useMemo(() => {
|
|
||||||
return users.find((u) => u.userName === userName) as
|
|
||||||
| UserProfile
|
|
||||||
| undefined;
|
|
||||||
}, [users, userName]);
|
|
||||||
|
|
||||||
const [editForm, setEditForm] = useState({
|
|
||||||
userName: "",
|
|
||||||
name: "",
|
|
||||||
});
|
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
|
|
||||||
const [selectedRoomValues, setSelectedRoomValues] = useState<string[]>([]);
|
|
||||||
const [isRoomDialogOpen, setIsRoomDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const roomOptions = useMemo(() => {
|
|
||||||
const list = Array.isArray(roomData) ? roomData : [];
|
|
||||||
return list
|
|
||||||
.map((room: any) => {
|
|
||||||
const rawValue =
|
|
||||||
room.id ??
|
|
||||||
room.roomId ??
|
|
||||||
room.roomID ??
|
|
||||||
room.Id ??
|
|
||||||
room.ID ??
|
|
||||||
room.RoomId ??
|
|
||||||
room.RoomID ??
|
|
||||||
room.name ??
|
|
||||||
room.roomName ??
|
|
||||||
room.RoomName ??
|
|
||||||
"";
|
|
||||||
const label =
|
|
||||||
room.name ?? room.roomName ?? room.RoomName ?? (rawValue ? String(rawValue) : "");
|
|
||||||
if (!rawValue || !label) return null;
|
|
||||||
return { label: String(label), value: String(rawValue) };
|
|
||||||
})
|
|
||||||
.filter((item): item is { label: string; value: string } => !!item);
|
|
||||||
}, [roomData]);
|
|
||||||
|
|
||||||
const roomLabelMap = useMemo(() => {
|
|
||||||
return new Map(roomOptions.map((room) => [room.value, room.label]));
|
|
||||||
}, [roomOptions]);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
setEditForm({
|
|
||||||
userName: user.userName ?? "",
|
|
||||||
name: user.name ?? "",
|
|
||||||
});
|
|
||||||
setSelectedRoleId(user.roleId ? String(user.roleId) : "");
|
|
||||||
setSelectedRoomValues(
|
|
||||||
Array.isArray(user.accessRooms)
|
|
||||||
? user.accessRooms.map((roomId) => String(roomId))
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleUpdateUserInfo = async () => {
|
|
||||||
if (!user?.userId) {
|
|
||||||
toast.error("Không tìm thấy userId để cập nhật.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUserName = editForm.userName.trim();
|
|
||||||
const nextName = editForm.name.trim();
|
|
||||||
|
|
||||||
if (!nextUserName || !nextName) {
|
|
||||||
toast.error("Vui lòng nhập đầy đủ tên đăng nhập và họ tên.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accessRooms = selectedRoomValues
|
|
||||||
.map((value) => Number(value))
|
|
||||||
.filter((value) => Number.isFinite(value));
|
|
||||||
|
|
||||||
if (
|
|
||||||
selectedRoomValues.length > 0 &&
|
|
||||||
accessRooms.length !== selectedRoomValues.length
|
|
||||||
) {
|
|
||||||
toast.error("Danh sách phòng không hợp lệ, vui lòng chọn lại.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateUserInfoMutation.mutateAsync({
|
|
||||||
id: user.userId,
|
|
||||||
data: {
|
|
||||||
userName: nextUserName,
|
|
||||||
name: nextName,
|
|
||||||
accessRooms,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
toast.success("Cập nhật thông tin người dùng thành công!");
|
|
||||||
} catch (error: any) {
|
|
||||||
const message = error?.response?.data?.message || "Cập nhật thất bại!";
|
|
||||||
toast.error(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateUserRole = async () => {
|
|
||||||
if (!user?.userId) {
|
|
||||||
toast.error("Không tìm thấy userId để cập nhật role.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedRoleId) {
|
|
||||||
toast.error("Vui lòng chọn vai trò.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateUserRoleMutation.mutateAsync({
|
|
||||||
id: user.userId,
|
|
||||||
data: { roleId: Number(selectedRoleId) },
|
|
||||||
});
|
|
||||||
toast.success("Cập nhật vai trò thành công!");
|
|
||||||
} catch (error: any) {
|
|
||||||
const message =
|
|
||||||
error?.response?.data?.message || "Cập nhật vai trò thất bại!";
|
|
||||||
toast.error(message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 py-8">
|
|
||||||
<div className="flex items-center justify-center min-h-[320px]">
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Đang tải thông tin người dùng...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 py-8 space-y-4">
|
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Quay lại
|
|
||||||
</Button>
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Không tìm thấy người dùng cần chỉnh sửa.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-6 py-6 space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
|
||||||
Chỉnh sửa người dùng
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
Tài khoản: {user.userName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate({ to: "/user" })}>
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
||||||
Quay lại
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Thông tin người dùng</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Cập nhật họ tên, username và danh sách phòng truy cập.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-userName">Tên đăng nhập</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-userName"
|
|
||||||
value={editForm.userName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditForm((prev) => ({ ...prev, userName: e.target.value }))
|
|
||||||
}
|
|
||||||
disabled={updateUserInfoMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="edit-name">Họ và tên</Label>
|
|
||||||
<Input
|
|
||||||
id="edit-name"
|
|
||||||
value={editForm.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setEditForm((prev) => ({ ...prev, name: e.target.value }))
|
|
||||||
}
|
|
||||||
disabled={updateUserInfoMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Các phòng phụ trách</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{selectedRoomValues.length > 0 ? (
|
|
||||||
selectedRoomValues.map((value) => (
|
|
||||||
<Badge key={value} variant="secondary">
|
|
||||||
{roomLabelMap.get(value) ?? value}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Chưa chọn phòng.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsRoomDialogOpen(true)}
|
|
||||||
disabled={roomsLoading || updateUserInfoMutation.isPending}
|
|
||||||
>
|
|
||||||
Chọn phòng
|
|
||||||
</Button>
|
|
||||||
{roomsLoading && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Đang tải danh sách phòng...
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleUpdateUserInfo}
|
|
||||||
disabled={updateUserInfoMutation.isPending}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{updateUserInfoMutation.isPending
|
|
||||||
? "Đang lưu..."
|
|
||||||
: "Lưu thông tin"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Vai trò</CardTitle>
|
|
||||||
<CardDescription>Cập nhật vai trò của người dùng.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2 max-w-md">
|
|
||||||
<Label>Vai trò</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedRoleId}
|
|
||||||
onValueChange={setSelectedRoleId}
|
|
||||||
disabled={rolesLoading || updateUserRoleMutation.isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={
|
|
||||||
rolesLoading ? "Đang tải vai trò..." : "Chọn vai trò"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{roles.map((role) => (
|
|
||||||
<SelectItem key={role.id} value={String(role.id)}>
|
|
||||||
{role.roleName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleUpdateUserRole}
|
|
||||||
disabled={updateUserRoleMutation.isPending || rolesLoading}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Save className="h-4 w-4" />
|
|
||||||
{updateUserRoleMutation.isPending ? "Đang lưu..." : "Lưu vai trò"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<SelectDialog
|
|
||||||
open={isRoomDialogOpen}
|
|
||||||
onClose={() => setIsRoomDialogOpen(false)}
|
|
||||||
title="Chọn phòng phụ trách"
|
|
||||||
description="Chọn một hoặc nhiều phòng để gán quyền truy cập."
|
|
||||||
items={roomOptions}
|
|
||||||
selectedValues={selectedRoomValues}
|
|
||||||
onConfirm={(values) => setSelectedRoomValues(values)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -10,13 +10,10 @@ import {
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
import { Edit2, Settings, Shield, Trash2 } from "lucide-react";
|
import { Edit2, Trash2, Shield } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/user/")({
|
export const Route = createFileRoute("/_auth/user/")({
|
||||||
head: () => ({
|
|
||||||
meta: [{ title: "Danh sách người dùng" }],
|
|
||||||
}),
|
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
context.breadcrumbs = [
|
context.breadcrumbs = [
|
||||||
|
|
@ -68,6 +65,21 @@ function RouteComponent() {
|
||||||
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
|
<div className="flex justify-center">{Array.isArray(getValue()) ? (getValue() as number[]).join(", ") : "-"}</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: () => <div className="text-center whitespace-normal max-w-xs">Chọn</div>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.getIsSelected?.() ?? false}
|
||||||
|
onChange={row.getToggleSelectedHandler?.()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
header: () => (
|
header: () => (
|
||||||
|
|
@ -75,78 +87,42 @@ function RouteComponent() {
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-2 justify-center items-center">
|
<div className="flex gap-2 justify-center items-center">
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
size="sm"
|
||||||
<Button
|
variant="ghost"
|
||||||
size="sm"
|
onClick={(e) => {
|
||||||
variant="ghost"
|
e.stopPropagation();
|
||||||
onClick={(e) => {
|
navigate({
|
||||||
e.stopPropagation();
|
to: "/user/change-password/$userName",
|
||||||
navigate({
|
params: { userName: row.original.userName },
|
||||||
to: "/user/edit/$userName",
|
} as any);
|
||||||
params: { userName: row.original.userName },
|
}}
|
||||||
} as any);
|
>
|
||||||
}}
|
<Edit2 className="h-4 w-4" />
|
||||||
>
|
</Button>
|
||||||
<Settings className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
</TooltipTrigger>
|
variant="ghost"
|
||||||
<TooltipContent side="top">Đổi thông tin</TooltipContent>
|
onClick={(e) => {
|
||||||
</Tooltip>
|
e.stopPropagation();
|
||||||
<Tooltip>
|
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any);
|
||||||
<TooltipTrigger asChild>
|
}}
|
||||||
<Button
|
>
|
||||||
size="sm"
|
<Shield className="h-4 w-4" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
onClick={(e) => {
|
<Button
|
||||||
e.stopPropagation();
|
size="sm"
|
||||||
navigate({
|
variant="ghost"
|
||||||
to: "/user/change-password/$userName",
|
onClick={async (e) => {
|
||||||
params: { userName: row.original.userName },
|
e.stopPropagation();
|
||||||
} as any);
|
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
|
||||||
}}
|
// Placeholder delete - implement API call as needed
|
||||||
>
|
toast.success("Xóa người dùng (chưa thực thi API)");
|
||||||
<Edit2 className="h-4 w-4" />
|
if (table) table.setRowSelection({});
|
||||||
</Button>
|
}}
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent side="top">Đổi mật khẩu</TooltipContent>
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate({
|
|
||||||
to: "/user/role/$roleId",
|
|
||||||
params: { roleId: String(row.original.roleId) },
|
|
||||||
} as any);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Shield className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">Xem quyền</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return;
|
|
||||||
// Placeholder delete - implement API call as needed
|
|
||||||
toast.success("Xóa người dùng (chưa thực thi API)");
|
|
||||||
if (table) table.setRowSelection({});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">Xóa người dùng</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type { PermissionOnRole } from "@/types/permission";
|
||||||
|
|
||||||
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
|
export const Route = createFileRoute("/_auth/user/role/$roleId/")({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: "Quyền của người dùng" }]
|
meta: [{ title: "Quyền của người dùng | AccessControl" }]
|
||||||
}),
|
}),
|
||||||
component: ViewRolePermissionsComponent,
|
component: ViewRolePermissionsComponent,
|
||||||
loader: async ({ context, params }) => {
|
loader: async ({ context, params }) => {
|
||||||
|
|
|
||||||
|
|
@ -108,32 +108,20 @@ export async function addRequiredFile(data: any): Promise<{ message: string }> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Xóa file bắt buộc
|
* Xóa file bắt buộc
|
||||||
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
|
* @param fileId - ID file
|
||||||
*/
|
*/
|
||||||
export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
|
export async function deleteRequiredFile(fileId: number): Promise<{ message: string }> {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE,
|
API_ENDPOINTS.APP_VERSION.DELETE_REQUIRED_FILE(fileId)
|
||||||
data
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Xóa file từ server
|
* Xóa file từ server
|
||||||
* @param data - DownloadMsiRequest { MsiFileIds: number[] }
|
* @param fileId - ID file
|
||||||
*/
|
*/
|
||||||
export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
|
export async function deleteFile(fileId: number): Promise<{ message: string }> {
|
||||||
const response = await axios.delete(
|
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId));
|
||||||
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
|
|
||||||
{ data }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gửi manifest (build + publish MQTT tới tất cả required files)
|
|
||||||
*/
|
|
||||||
export async function sendManifest(): Promise<{ status: string; message: string }> {
|
|
||||||
const response = await axios.post(API_ENDPOINTS.MANIFEST.SEND_ALL);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import axios from "@/config/axios";
|
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
|
||||||
import type { PageResult, Audits } from "@/types/audit";
|
|
||||||
|
|
||||||
export async function getAudits(
|
|
||||||
pageNumber = 1,
|
|
||||||
pageSize = 20,
|
|
||||||
username?: string | null,
|
|
||||||
action?: string | null,
|
|
||||||
from?: string | null,
|
|
||||||
to?: string | null
|
|
||||||
): Promise<PageResult<Audits>> {
|
|
||||||
const response = await axios.get<PageResult<Audits>>(API_ENDPOINTS.AUDIT.GET_AUDITS, {
|
|
||||||
params: { pageNumber, pageSize, username, action, from, to },
|
|
||||||
});
|
|
||||||
|
|
||||||
// API trả về camelCase khớp với PageResult<Audits> — dùng trực tiếp, không cần map
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import rawAxios from "axios";
|
|
||||||
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
import type { LoginResquest, LoginResponse, CreateAccountRequest } from "@/types/auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,96 +15,6 @@ export async function login(credentials: LoginResquest): Promise<LoginResponse>
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build OAuth login URL by provider
|
|
||||||
* @param provider - OAuth provider key (e.g. google, azuread)
|
|
||||||
* @param returnUrl - FE callback url
|
|
||||||
*/
|
|
||||||
export function buildOAuthLoginUrl(provider: string, returnUrl: string): string {
|
|
||||||
const base = API_ENDPOINTS.AUTH.OAUTH_LOGIN(provider);
|
|
||||||
const encoded = encodeURIComponent(returnUrl);
|
|
||||||
return `${base}?returnUrl=${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Google OAuth login URL
|
|
||||||
* @param returnUrl - FE callback url
|
|
||||||
*/
|
|
||||||
export function buildGoogleOAuthLoginUrl(returnUrl: string): string {
|
|
||||||
return buildOAuthLoginUrl("google", returnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Microsoft SSO login URL (legacy endpoint)
|
|
||||||
* @param returnUrl - FE callback url
|
|
||||||
*/
|
|
||||||
export function buildMicrosoftSsoLoginUrl(returnUrl: string): string {
|
|
||||||
const base = API_ENDPOINTS.AUTH.SSO_LOGIN;
|
|
||||||
const encoded = encodeURIComponent(returnUrl);
|
|
||||||
return `${base}?returnUrl=${encoded}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange one-time OAuth code for login payload
|
|
||||||
* @param code - one-time code
|
|
||||||
*/
|
|
||||||
export async function exchangeOAuthCode(code: string): Promise<LoginResponse> {
|
|
||||||
try {
|
|
||||||
const response = await rawAxios.post<LoginResponse>(
|
|
||||||
API_ENDPOINTS.AUTH.OAUTH_EXCHANGE,
|
|
||||||
{ code }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
if (rawAxios.isAxiosError(error)) {
|
|
||||||
const status = error.response?.status;
|
|
||||||
if (status === 401 || status === 404 || status === 405) {
|
|
||||||
const fallbackResponse = await rawAxios.post<LoginResponse>(
|
|
||||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
|
||||||
{ code }
|
|
||||||
);
|
|
||||||
return fallbackResponse.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy AzureAD SSO URL builder kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
export function buildSsoLoginUrl(returnUrl: string): string {
|
|
||||||
return buildOAuthLoginUrl("azuread", returnUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legacy SSO exchange alias kept for backward compatibility.
|
|
||||||
*/
|
|
||||||
export async function exchangeSsoCode(code: string): Promise<LoginResponse> {
|
|
||||||
return exchangeOAuthCode(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchange one-time code by provider without breaking existing flows.
|
|
||||||
* - azuread/microsoft: force legacy SSO exchange endpoint
|
|
||||||
* - default: use OAuth exchange flow
|
|
||||||
*/
|
|
||||||
export async function exchangeCodeByProvider(
|
|
||||||
code: string,
|
|
||||||
provider?: string
|
|
||||||
): Promise<LoginResponse> {
|
|
||||||
const providerKey = (provider || "").toLowerCase();
|
|
||||||
if (providerKey === "microsoft" || providerKey === "azuread") {
|
|
||||||
const response = await rawAxios.post<LoginResponse>(
|
|
||||||
API_ENDPOINTS.AUTH.SSO_EXCHANGE,
|
|
||||||
{ code }
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return exchangeOAuthCode(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Đăng xuất
|
* Đăng xuất
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import axios from "@/config/axios";
|
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
|
||||||
import type {
|
|
||||||
DashboardSummaryResponse,
|
|
||||||
DashboardGeneralInfo,
|
|
||||||
DeviceOverviewResponse,
|
|
||||||
DeviceStatusByRoom,
|
|
||||||
RoomManagementResponse,
|
|
||||||
RoomUsageResponse,
|
|
||||||
SoftwareDistributionResponse,
|
|
||||||
} from "@/types/dashboard";
|
|
||||||
|
|
||||||
export async function getDashboardSummary(): Promise<DashboardSummaryResponse> {
|
|
||||||
const response = await axios.get<DashboardSummaryResponse>(API_ENDPOINTS.DASHBOARD.GET_SUMMARY);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDashboardGeneralInfo(): Promise<DashboardGeneralInfo> {
|
|
||||||
const response = await axios.get<DashboardGeneralInfo>(API_ENDPOINTS.DASHBOARD.GET_GENERAL);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRoomUsage(): Promise<RoomUsageResponse> {
|
|
||||||
const response = await axios.get<RoomUsageResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOM_USAGE);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeviceOverview(): Promise<DeviceOverviewResponse> {
|
|
||||||
const response = await axios.get<DeviceOverviewResponse>(API_ENDPOINTS.DASHBOARD.GET_DEVICE_OVERVIEW);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDeviceStatusByRoom(): Promise<DeviceStatusByRoom[]> {
|
|
||||||
const response = await axios.get<DeviceStatusByRoom[]>(API_ENDPOINTS.DASHBOARD.GET_DEVICES_BY_ROOM);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRoomManagement(): Promise<RoomManagementResponse> {
|
|
||||||
const response = await axios.get<RoomManagementResponse>(API_ENDPOINTS.DASHBOARD.GET_ROOMS);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSoftwareDistribution(): Promise<SoftwareDistributionResponse> {
|
|
||||||
const response = await axios.get<SoftwareDistributionResponse>(API_ENDPOINTS.DASHBOARD.GET_SOFTWARE);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type { DeviceHealthCheck } from "@/types/device";
|
import type { DeviceHealthCheck } from "@/types/device";
|
||||||
import type { Room } from "@/types/room";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lấy tất cả thiết bị trong hệ thống
|
* Lấy tất cả thiết bị trong hệ thống
|
||||||
|
|
@ -14,8 +13,8 @@ export async function getAllDevices(): Promise<any[]> {
|
||||||
/**
|
/**
|
||||||
* Lấy danh sách phòng
|
* Lấy danh sách phòng
|
||||||
*/
|
*/
|
||||||
export async function getRoomList(): Promise<Room[]> {
|
export async function getRoomList(): Promise<any[]> {
|
||||||
const response = await axios.get<Room[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
|
const response = await axios.get<any[]>(API_ENDPOINTS.DEVICE_COMM.GET_ROOM_LIST);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,3 @@ export * as roleService from "./role.service";
|
||||||
|
|
||||||
// Mesh Central API Services
|
// Mesh Central API Services
|
||||||
export * as meshCentralService from "./meshcentral.service";
|
export * as meshCentralService from "./meshcentral.service";
|
||||||
|
|
||||||
// Dashboard API Services
|
|
||||||
export * as dashboardService from "./dashboard.service";
|
|
||||||
|
|
||||||
// Remote Control API Services
|
|
||||||
export * as remoteControlService from "./remote-control.service";
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import axios from "@/config/axios";
|
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
|
||||||
|
|
||||||
export type RemoteDesktopResponse = {
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getRemoteDesktopUrl(nodeId: string): Promise<RemoteDesktopResponse> {
|
|
||||||
const response = await axios.get<RemoteDesktopResponse>(
|
|
||||||
API_ENDPOINTS.MESH_CENTRAL.GET_REMOTE_DESKTOP(nodeId.trim())
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +1,6 @@
|
||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import { API_ENDPOINTS } from "@/config/api";
|
import { API_ENDPOINTS } from "@/config/api";
|
||||||
import type {
|
import type { UserProfile } from "@/types/user-profile";
|
||||||
UserProfile,
|
|
||||||
UpdateUserInfoRequest,
|
|
||||||
UpdateUserRoleRequest,
|
|
||||||
UpdateUserInfoResponse,
|
|
||||||
UpdateUserRoleResponse,
|
|
||||||
} from "@/types/user-profile";
|
|
||||||
|
|
||||||
// Helper to extract data from wrapped or unwrapped response
|
|
||||||
function extractData<T>(responseData: any): T {
|
|
||||||
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
|
|
||||||
return responseData.data as T;
|
|
||||||
}
|
|
||||||
return responseData as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lấy danh sách thông tin người dùng và chuyển sang camelCase keys
|
* Lấy danh sách thông tin người dùng và chuyển sang camelCase keys
|
||||||
|
|
@ -25,7 +11,6 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
|
||||||
const list = Array.isArray(response.data) ? response.data : [];
|
const list = Array.isArray(response.data) ? response.data : [];
|
||||||
|
|
||||||
return list.map((u: any) => ({
|
return list.map((u: any) => ({
|
||||||
userId: u.id ?? u.Id ?? u.userId ?? u.UserId ?? undefined,
|
|
||||||
userName: u.userName ?? u.UserName ?? "",
|
userName: u.userName ?? u.UserName ?? "",
|
||||||
name: u.name ?? u.Name ?? "",
|
name: u.name ?? u.Name ?? "",
|
||||||
role: u.role ?? u.Role ?? "",
|
role: u.role ?? u.Role ?? "",
|
||||||
|
|
@ -46,32 +31,4 @@ export async function getUsersInfo(): Promise<UserProfile[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default { getUsersInfo };
|
||||||
* Cập nhật thông tin người dùng
|
|
||||||
*/
|
|
||||||
export async function updateUserInfo(
|
|
||||||
userId: number,
|
|
||||||
data: UpdateUserInfoRequest
|
|
||||||
): Promise<UpdateUserInfoResponse> {
|
|
||||||
const response = await axios.put(
|
|
||||||
API_ENDPOINTS.USER.UPDATE_INFO(userId),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return extractData<UpdateUserInfoResponse>(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cập nhật role người dùng
|
|
||||||
*/
|
|
||||||
export async function updateUserRole(
|
|
||||||
userId: number,
|
|
||||||
data: UpdateUserRoleRequest
|
|
||||||
): Promise<UpdateUserRoleResponse> {
|
|
||||||
const response = await axios.put(
|
|
||||||
API_ENDPOINTS.USER.UPDATE_ROLE(userId),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
return extractData<UpdateUserRoleResponse>(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { getUsersInfo, updateUserInfo, updateUserRole };
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { RequestUpdateMenu } from "@/components/menu/request-update-menu";
|
||||||
import { DeleteMenu } from "@/components/menu/delete-menu";
|
import { DeleteMenu } from "@/components/menu/delete-menu";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { AxiosProgressEvent } from "axios";
|
import type { AxiosProgressEvent } from "axios";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
import { SelectDialog } from "@/components/dialogs/select-dialog";
|
||||||
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
import { DeviceSearchDialog } from "@/components/bars/device-searchbar";
|
||||||
import { UploadVersionForm } from "@/components/forms/upload-file-form";
|
import { UploadVersionForm } from "@/components/forms/upload-file-form";
|
||||||
|
|
@ -43,8 +43,6 @@ interface AppManagerTemplateProps<TData> {
|
||||||
deleteLoading?: boolean;
|
deleteLoading?: boolean;
|
||||||
onAddToRequired?: () => Promise<void> | void;
|
onAddToRequired?: () => Promise<void> | void;
|
||||||
addToRequiredLoading?: boolean;
|
addToRequiredLoading?: boolean;
|
||||||
onSendManifest?: (targetNames: string[]) => Promise<void> | void;
|
|
||||||
sendManifestLoading?: boolean;
|
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
rooms?: Room[];
|
rooms?: Room[];
|
||||||
devices?: string[];
|
devices?: string[];
|
||||||
|
|
@ -72,8 +70,6 @@ export function AppManagerTemplate<TData>({
|
||||||
deleteLoading,
|
deleteLoading,
|
||||||
onAddToRequired,
|
onAddToRequired,
|
||||||
addToRequiredLoading,
|
addToRequiredLoading,
|
||||||
onSendManifest,
|
|
||||||
sendManifestLoading,
|
|
||||||
onTableInit,
|
onTableInit,
|
||||||
rooms = [],
|
rooms = [],
|
||||||
devices = [],
|
devices = [],
|
||||||
|
|
@ -82,23 +78,7 @@ export function AppManagerTemplate<TData>({
|
||||||
pageSizeOptions = [5, 10, 15, 20],
|
pageSizeOptions = [5, 10, 15, 20],
|
||||||
}: AppManagerTemplateProps<TData>) {
|
}: AppManagerTemplateProps<TData>) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | "manifest-room" | "manifest-device" | null>(null);
|
const [dialogType, setDialogType] = useState<"room" | "device" | "download-room" | "download-device" | null>(null);
|
||||||
|
|
||||||
const sortedData = useMemo(() => {
|
|
||||||
const firstItem = data?.[0] as { fileName?: string } | undefined;
|
|
||||||
if (!firstItem || typeof firstItem.fileName !== "string") {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...data].sort((a, b) => {
|
|
||||||
const aName = (a as { fileName?: string }).fileName ?? "";
|
|
||||||
const bName = (b as { fileName?: string }).fileName ?? "";
|
|
||||||
return aName.localeCompare(bName, "vi", {
|
|
||||||
numeric: true,
|
|
||||||
sensitivity: "base",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const openRoomDialog = () => {
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onUpdate) {
|
if (rooms.length > 0 && onUpdate) {
|
||||||
|
|
@ -128,26 +108,6 @@ export function AppManagerTemplate<TData>({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const openManifestRoomDialog = () => {
|
|
||||||
if (rooms.length > 0 && onSendManifest) {
|
|
||||||
setDialogType("manifest-room");
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openManifestDeviceDialog = () => {
|
|
||||||
if (onSendManifest) {
|
|
||||||
setDialogType("manifest-device");
|
|
||||||
setDialogOpen(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleManifestAll = async () => {
|
|
||||||
if (!onSendManifest) return;
|
|
||||||
await onSendManifest([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdateAll = async () => {
|
const handleUpdateAll = async () => {
|
||||||
if (!onUpdate) return;
|
if (!onUpdate) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -189,7 +149,7 @@ export function AppManagerTemplate<TData>({
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<VersionTable
|
<VersionTable
|
||||||
data={sortedData}
|
data={data}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onTableInit={onTableInit}
|
onTableInit={onTableInit}
|
||||||
|
|
@ -241,18 +201,6 @@ export function AppManagerTemplate<TData>({
|
||||||
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
|
{addToRequiredLoading ? "Đang thêm..." : "Thêm vào danh sách"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{onSendManifest && (
|
|
||||||
<RequestUpdateMenu
|
|
||||||
onUpdateAll={handleManifestAll}
|
|
||||||
onUpdateRoom={openManifestRoomDialog}
|
|
||||||
onUpdateDevice={openManifestDeviceDialog}
|
|
||||||
loading={sendManifestLoading}
|
|
||||||
label="Gửi Manifest"
|
|
||||||
allLabel="Gửi tất cả"
|
|
||||||
roomLabel="Gửi theo phòng"
|
|
||||||
deviceLabel="Gửi theo thiết bị"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{onDeleteFromServer && onDeleteFromRequired && (
|
{onDeleteFromServer && onDeleteFromRequired && (
|
||||||
<DeleteMenu
|
<DeleteMenu
|
||||||
|
|
@ -351,8 +299,7 @@ export function AppManagerTemplate<TData>({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Dialog tải file - tìm thiết bị */}
|
||||||
{/* Dialog tải file - tìm thiết bị */}
|
|
||||||
{dialogType === "download-device" && (
|
{dialogType === "download-device" && (
|
||||||
<DeviceSearchDialog
|
<DeviceSearchDialog
|
||||||
open={dialogOpen && dialogType === "download-device"}
|
open={dialogOpen && dialogType === "download-device"}
|
||||||
|
|
@ -381,60 +328,6 @@ export function AppManagerTemplate<TData>({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog gửi manifest - chọn phòng */}
|
|
||||||
{dialogType === "manifest-room" && (
|
|
||||||
<SelectDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onClose={() => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDialogType(null);
|
|
||||||
}}
|
|
||||||
title="Chọn phòng"
|
|
||||||
description="Chọn các phòng để gửi manifest"
|
|
||||||
icon={<Building2 className="w-6 h-6 text-primary" />}
|
|
||||||
items={mapRoomsToSelectItems(rooms)}
|
|
||||||
onConfirm={async (selectedItems) => {
|
|
||||||
if (!onSendManifest) return;
|
|
||||||
try {
|
|
||||||
await onSendManifest(selectedItems);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Send manifest error:", e);
|
|
||||||
} finally {
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDialogType(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dialog gửi manifest - chọn thiết bị */}
|
|
||||||
{dialogType === "manifest-device" && (
|
|
||||||
<DeviceSearchDialog
|
|
||||||
open={dialogOpen && dialogType === "manifest-device"}
|
|
||||||
onClose={() => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDialogType(null);
|
|
||||||
}}
|
|
||||||
rooms={rooms}
|
|
||||||
fetchDevices={getDeviceFromRoom}
|
|
||||||
onSelect={async (deviceIds) => {
|
|
||||||
if (!onSendManifest) {
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDialogType(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await onSendManifest(deviceIds);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Send manifest error:", e);
|
|
||||||
} finally {
|
|
||||||
setDialogOpen(false);
|
|
||||||
setDialogType(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,242 +0,0 @@
|
||||||
import {
|
|
||||||
type ColumnDef,
|
|
||||||
flexRender,
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
TableBody,
|
|
||||||
TableRow,
|
|
||||||
TableHead,
|
|
||||||
TableCell,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { type Audits } from "@/types/audit";
|
|
||||||
import { AuditFilterBar } from "@/components/filters/audit-filter-bar";
|
|
||||||
import { AuditDetailDialog } from "@/components/dialogs/audit-detail-dialog";
|
|
||||||
|
|
||||||
interface AuditListTemplateProps {
|
|
||||||
// data
|
|
||||||
items: Audits[];
|
|
||||||
total: number;
|
|
||||||
columns: ColumnDef<Audits>[];
|
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
|
|
||||||
// pagination
|
|
||||||
pageNumber: number;
|
|
||||||
pageSize: number;
|
|
||||||
pageCount: number;
|
|
||||||
onPreviousPage: () => void;
|
|
||||||
onNextPage: () => void;
|
|
||||||
canPreviousPage: boolean;
|
|
||||||
canNextPage: boolean;
|
|
||||||
|
|
||||||
// filter
|
|
||||||
username: string | null;
|
|
||||||
action: string | null;
|
|
||||||
from: string | null;
|
|
||||||
to: string | null;
|
|
||||||
onUsernameChange: (v: string | null) => void;
|
|
||||||
onActionChange: (v: string | null) => void;
|
|
||||||
onFromChange: (v: string | null) => void;
|
|
||||||
onToChange: (v: string | null) => void;
|
|
||||||
onSearch: () => void;
|
|
||||||
onReset: () => void;
|
|
||||||
|
|
||||||
// detail dialog
|
|
||||||
selectedAudit: Audits | null;
|
|
||||||
onRowClick: (audit: Audits) => void;
|
|
||||||
onDialogClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuditListTemplate({
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
columns,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
pageNumber,
|
|
||||||
pageSize,
|
|
||||||
pageCount,
|
|
||||||
onPreviousPage,
|
|
||||||
onNextPage,
|
|
||||||
canPreviousPage,
|
|
||||||
canNextPage,
|
|
||||||
username,
|
|
||||||
action,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
onUsernameChange,
|
|
||||||
onActionChange,
|
|
||||||
onFromChange,
|
|
||||||
onToChange,
|
|
||||||
onSearch,
|
|
||||||
onReset,
|
|
||||||
selectedAudit,
|
|
||||||
onRowClick,
|
|
||||||
onDialogClose,
|
|
||||||
}: AuditListTemplateProps) {
|
|
||||||
const table = useReactTable({
|
|
||||||
data: items,
|
|
||||||
columns,
|
|
||||||
state: {
|
|
||||||
pagination: { pageIndex: Math.max(0, pageNumber - 1), pageSize },
|
|
||||||
},
|
|
||||||
pageCount,
|
|
||||||
manualPagination: true,
|
|
||||||
onPaginationChange: (updater) => {
|
|
||||||
const next =
|
|
||||||
typeof updater === "function"
|
|
||||||
? updater({ pageIndex: Math.max(0, pageNumber - 1), pageSize })
|
|
||||||
: updater;
|
|
||||||
const newPage = (next.pageIndex ?? 0) + 1;
|
|
||||||
if (newPage > pageNumber) onNextPage();
|
|
||||||
else if (newPage < pageNumber) onPreviousPage();
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full px-4 md:px-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold">Nhật ký hoạt động</h1>
|
|
||||||
<p className="text-muted-foreground mt-1 text-sm">
|
|
||||||
Xem nhật ký audit hệ thống
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-base">Danh sách audit</CardTitle>
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
Lọc theo người dùng, loại, hành động và khoảng thời gian. Nhấn vào
|
|
||||||
dòng để xem chi tiết.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<AuditFilterBar
|
|
||||||
username={username}
|
|
||||||
action={action}
|
|
||||||
from={from}
|
|
||||||
to={to}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isFetching={isFetching}
|
|
||||||
onUsernameChange={onUsernameChange}
|
|
||||||
onActionChange={onActionChange}
|
|
||||||
onFromChange={onFromChange}
|
|
||||||
onToChange={onToChange}
|
|
||||||
onSearch={onSearch}
|
|
||||||
onReset={onReset}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="rounded-md border overflow-x-auto">
|
|
||||||
<Table className="min-w-[640px] w-full">
|
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
|
||||||
{table.getHeaderGroups().map((hg) => (
|
|
||||||
<TableRow key={hg.id}>
|
|
||||||
{hg.headers.map((header) => (
|
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
className="text-xs font-semibold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableHeader>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{isLoading || isFetching ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="text-center py-10 text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Đang tải...
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : table.getRowModel().rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={columns.length}
|
|
||||||
className="text-center py-10 text-muted-foreground text-sm"
|
|
||||||
>
|
|
||||||
Không có dữ liệu
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className="hover:bg-muted/40 cursor-pointer"
|
|
||||||
onClick={() => onRowClick(row.original)}
|
|
||||||
>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell key={cell.id} className="py-2.5 align-middle">
|
|
||||||
{cell.column.columnDef.cell
|
|
||||||
? flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext()
|
|
||||||
)
|
|
||||||
: String(cell.getValue() ?? "")}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Hiển thị {items.length} / {total} mục
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canPreviousPage || isFetching}
|
|
||||||
onClick={onPreviousPage}
|
|
||||||
>
|
|
||||||
Trước
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm tabular-nums">
|
|
||||||
{pageNumber} / {pageCount}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!canNextPage || isFetching}
|
|
||||||
onClick={onNextPage}
|
|
||||||
>
|
|
||||||
Sau
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<AuditDetailDialog
|
|
||||||
audit={selectedAudit}
|
|
||||||
open={!!selectedAudit}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -11,13 +11,9 @@ import { Plus, CommandIcon, Zap, Building2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import type { ColumnDef } from "@tanstack/react-table";
|
import type { ColumnDef } from "@tanstack/react-table";
|
||||||
import { VersionTable } from "@/components/tables/version-table";
|
import { VersionTable } from "@/components/tables/version-table";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,11 +28,6 @@ import { getDeviceFromRoom } from "@/services/device-comm.service";
|
||||||
import type { Room } from "@/types/room";
|
import type { Room } from "@/types/room";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export interface SendCommandOptions {
|
|
||||||
ttlMinutes?: number;
|
|
||||||
sendTime?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -60,15 +51,8 @@ interface CommandSubmitTemplateProps<T extends { id: number }> {
|
||||||
onTableInit?: (table: any) => void;
|
onTableInit?: (table: any) => void;
|
||||||
|
|
||||||
// Execute
|
// Execute
|
||||||
onExecuteSelected?: (
|
onExecuteSelected?: (targets: string[]) => void;
|
||||||
targets: string[],
|
onExecuteCustom?: (targets: string[], commandData: ShellCommandData) => void;
|
||||||
options?: SendCommandOptions
|
|
||||||
) => void | Promise<void>;
|
|
||||||
onExecuteCustom?: (
|
|
||||||
targets: string[],
|
|
||||||
commandData: ShellCommandData,
|
|
||||||
options?: SendCommandOptions
|
|
||||||
) => void | Promise<void>;
|
|
||||||
isExecuting?: boolean;
|
isExecuting?: boolean;
|
||||||
|
|
||||||
// Execution scope
|
// Execution scope
|
||||||
|
|
@ -129,158 +113,17 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
const [customCommand, setCustomCommand] = useState("");
|
const [customCommand, setCustomCommand] = useState("");
|
||||||
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
const [customQoS, setCustomQoS] = useState<0 | 1 | 2>(0);
|
||||||
const [customRetained, setCustomRetained] = useState(false);
|
const [customRetained, setCustomRetained] = useState(false);
|
||||||
|
const [table, setTable] = useState<any>();
|
||||||
const [dialogOpen2, setDialogOpen2] = useState(false);
|
const [dialogOpen2, setDialogOpen2] = useState(false);
|
||||||
const [dialogType, setDialogType] = useState<
|
const [dialogType, setDialogType] = useState<
|
||||||
"room" | "device" | "room-custom" | "device-custom" | null
|
"room" | "device" | "room-custom" | "device-custom" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
const [ttlMinutesInput, setTtlMinutesInput] = useState("");
|
|
||||||
const [sendTimeInput, setSendTimeInput] = useState("");
|
|
||||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
|
||||||
const [pendingAction, setPendingAction] = useState<
|
|
||||||
| { type: "selected"; targets: string[] }
|
|
||||||
| { type: "custom"; targets: string[]; commandData: ShellCommandData }
|
|
||||||
| null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleTableInit = (t: any) => {
|
const handleTableInit = (t: any) => {
|
||||||
|
setTable(t);
|
||||||
onTableInit?.(t);
|
onTableInit?.(t);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeTargetDialog = () => {
|
|
||||||
setDialogOpen2(false);
|
|
||||||
setDialogType(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetConfirmState = () => {
|
|
||||||
setConfirmOpen(false);
|
|
||||||
setPendingAction(null);
|
|
||||||
setTtlMinutesInput("");
|
|
||||||
setSendTimeInput("");
|
|
||||||
setConfirmError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatLocalSendTime = (date: Date) => {
|
|
||||||
const pad2 = (value: number) => String(value).padStart(2, "0");
|
|
||||||
const hh = pad2(date.getHours());
|
|
||||||
const mm = pad2(date.getMinutes());
|
|
||||||
const ss = pad2(date.getSeconds());
|
|
||||||
const dd = pad2(date.getDate());
|
|
||||||
const MM = pad2(date.getMonth() + 1);
|
|
||||||
const yy = pad2(date.getFullYear() % 100);
|
|
||||||
return `${hh}:${mm}:${ss} ${dd}/${MM}/${yy}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const openConfirmForSelected = (targets: string[]) => {
|
|
||||||
setPendingAction({ type: "selected", targets });
|
|
||||||
setSendTimeInput(formatLocalSendTime(new Date()));
|
|
||||||
setConfirmOpen(true);
|
|
||||||
setConfirmError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openConfirmForCustom = (
|
|
||||||
targets: string[],
|
|
||||||
commandData: ShellCommandData
|
|
||||||
) => {
|
|
||||||
setPendingAction({ type: "custom", targets, commandData });
|
|
||||||
setSendTimeInput(formatLocalSendTime(new Date()));
|
|
||||||
setConfirmOpen(true);
|
|
||||||
setConfirmError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSendOptions = (): {
|
|
||||||
options?: SendCommandOptions;
|
|
||||||
error?: string;
|
|
||||||
} => {
|
|
||||||
const options: SendCommandOptions = {};
|
|
||||||
const ttlTrimmed = ttlMinutesInput.trim();
|
|
||||||
if (ttlTrimmed) {
|
|
||||||
const parsedTtl = Number(ttlTrimmed);
|
|
||||||
if (!Number.isInteger(parsedTtl) || parsedTtl < 0) {
|
|
||||||
return { error: "TtlMinutes phải là số nguyên >= 0." };
|
|
||||||
}
|
|
||||||
options.ttlMinutes = parsedTtl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendTrimmed = sendTimeInput.trim();
|
|
||||||
if (sendTrimmed) {
|
|
||||||
const match =
|
|
||||||
/^(\d{2}):(\d{2}):(\d{2})\s+(\d{2})\/(\d{2})\/(\d{2})$/.exec(
|
|
||||||
sendTrimmed
|
|
||||||
);
|
|
||||||
if (!match) {
|
|
||||||
return { error: "SendTime không đúng định dạng HH:MM:SS DD/MM/YY." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, hh, mm, ss, dd, MM, yy] = match;
|
|
||||||
const hour = Number(hh);
|
|
||||||
const minute = Number(mm);
|
|
||||||
const second = Number(ss);
|
|
||||||
const day = Number(dd);
|
|
||||||
const month = Number(MM);
|
|
||||||
const year = 2000 + Number(yy);
|
|
||||||
|
|
||||||
if (
|
|
||||||
hour > 23 ||
|
|
||||||
minute > 59 ||
|
|
||||||
second > 59 ||
|
|
||||||
month < 1 ||
|
|
||||||
month > 12 ||
|
|
||||||
day < 1 ||
|
|
||||||
day > 31
|
|
||||||
) {
|
|
||||||
return { error: "SendTime không hợp lệ." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(year, month - 1, day, hour, minute, second);
|
|
||||||
if (
|
|
||||||
date.getFullYear() !== year ||
|
|
||||||
date.getMonth() !== month - 1 ||
|
|
||||||
date.getDate() !== day ||
|
|
||||||
date.getHours() !== hour ||
|
|
||||||
date.getMinutes() !== minute ||
|
|
||||||
date.getSeconds() !== second
|
|
||||||
) {
|
|
||||||
return { error: "SendTime không hợp lệ." };
|
|
||||||
}
|
|
||||||
|
|
||||||
options.sendTime = date;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { options };
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleConfirmSend = async () => {
|
|
||||||
if (!pendingAction) return;
|
|
||||||
|
|
||||||
const { options, error } = parseSendOptions();
|
|
||||||
if (error) {
|
|
||||||
setConfirmError(error);
|
|
||||||
toast.error(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (pendingAction.type === "selected") {
|
|
||||||
await onExecuteSelected?.(pendingAction.targets, options);
|
|
||||||
} else {
|
|
||||||
await onExecuteCustom?.(
|
|
||||||
pendingAction.targets,
|
|
||||||
pendingAction.commandData,
|
|
||||||
options
|
|
||||||
);
|
|
||||||
setCustomCommand("");
|
|
||||||
setCustomQoS(0);
|
|
||||||
setCustomRetained(false);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Confirm send error:", e);
|
|
||||||
} finally {
|
|
||||||
resetConfirmState();
|
|
||||||
setTimeout(() => window.location.reload(), 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openRoomDialog = () => {
|
const openRoomDialog = () => {
|
||||||
if (rooms.length > 0 && onExecuteSelected) {
|
if (rooms.length > 0 && onExecuteSelected) {
|
||||||
setDialogType("room");
|
setDialogType("room");
|
||||||
|
|
@ -295,6 +138,21 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExecuteSelected = () => {
|
||||||
|
if (!table) {
|
||||||
|
toast.error("Không thể lấy thông tin bảng!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRows = table.getSelectedRowModel().rows;
|
||||||
|
if (selectedRows.length === 0) {
|
||||||
|
toast.error("Vui lòng chọn ít nhất một mục để thực thi!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onExecuteSelected?.([]);
|
||||||
|
};
|
||||||
|
|
||||||
const handleExecuteAll = () => {
|
const handleExecuteAll = () => {
|
||||||
if (!onExecuteSelected) return;
|
if (!onExecuteSelected) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -302,7 +160,7 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
typeof room === "string" ? room : room.name,
|
typeof room === "string" ? room : room.name,
|
||||||
);
|
);
|
||||||
const allTargets = [...roomNames, ...devices];
|
const allTargets = [...roomNames, ...devices];
|
||||||
openConfirmForSelected(allTargets);
|
onExecuteSelected(allTargets);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
}
|
}
|
||||||
|
|
@ -320,7 +178,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
isRetained: customRetained,
|
isRetained: customRetained,
|
||||||
};
|
};
|
||||||
|
|
||||||
openConfirmForCustom(targets, shellCommandData);
|
try {
|
||||||
|
await onExecuteCustom?.(targets, shellCommandData);
|
||||||
|
setCustomCommand("");
|
||||||
|
setCustomQoS(0);
|
||||||
|
setCustomRetained(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Execute custom command error:", e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExecuteCustomAll = () => {
|
const handleExecuteCustomAll = () => {
|
||||||
|
|
@ -478,7 +343,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen2}
|
open={dialogOpen2}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}}
|
}}
|
||||||
title="Chọn phòng"
|
title="Chọn phòng"
|
||||||
description="Chọn các phòng để thực thi lệnh"
|
description="Chọn các phòng để thực thi lệnh"
|
||||||
|
|
@ -487,11 +354,13 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
onConfirm={async (selectedItems) => {
|
onConfirm={async (selectedItems) => {
|
||||||
if (!onExecuteSelected) return;
|
if (!onExecuteSelected) return;
|
||||||
try {
|
try {
|
||||||
openConfirmForSelected(selectedItems);
|
await onExecuteSelected(selectedItems);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -502,21 +371,27 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<DeviceSearchDialog
|
<DeviceSearchDialog
|
||||||
open={dialogOpen2 && dialogType === "device"}
|
open={dialogOpen2 && dialogType === "device"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}}
|
}}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
fetchDevices={getDeviceFromRoom}
|
fetchDevices={getDeviceFromRoom}
|
||||||
onSelect={async (deviceIds) => {
|
onSelect={async (deviceIds) => {
|
||||||
if (!onExecuteSelected) {
|
if (!onExecuteSelected) {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
openConfirmForSelected(deviceIds);
|
await onExecuteSelected(deviceIds);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -527,7 +402,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<SelectDialog
|
<SelectDialog
|
||||||
open={dialogOpen2}
|
open={dialogOpen2}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}}
|
}}
|
||||||
title="Chọn phòng"
|
title="Chọn phòng"
|
||||||
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
description="Chọn các phòng để thực thi lệnh tùy chỉnh"
|
||||||
|
|
@ -539,7 +416,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -550,7 +429,9 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
<DeviceSearchDialog
|
<DeviceSearchDialog
|
||||||
open={dialogOpen2 && dialogType === "device-custom"}
|
open={dialogOpen2 && dialogType === "device-custom"}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}}
|
}}
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
fetchDevices={getDeviceFromRoom}
|
fetchDevices={getDeviceFromRoom}
|
||||||
|
|
@ -560,67 +441,14 @@ export function CommandSubmitTemplate<T extends { id: number }>({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Execute error:", e);
|
console.error("Execute error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
closeTargetDialog();
|
setDialogOpen2(false);
|
||||||
|
setDialogType(null);
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dialog xác nhận gửi lệnh */}
|
|
||||||
<Dialog
|
|
||||||
open={confirmOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) resetConfirmState();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Xác nhận gửi lệnh</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Vui lòng xác nhận và nhập thông tin bổ sung trước khi gửi.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ttl-minutes">TtlMinutes (phút)</Label>
|
|
||||||
<Input
|
|
||||||
id="ttl-minutes"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
placeholder="VD: 60"
|
|
||||||
value={ttlMinutesInput}
|
|
||||||
onChange={(e) => setTtlMinutesInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="send-time">SendTime</Label>
|
|
||||||
<Input
|
|
||||||
id="send-time"
|
|
||||||
placeholder="HH:MM:SS DD/MM/YY (VD: 14:30:00 25/05/26)"
|
|
||||||
value={sendTimeInput}
|
|
||||||
onChange={(e) => setSendTimeInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Để trống nếu muốn gửi ngay.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{confirmError && (
|
|
||||||
<p className="text-sm text-red-600">{confirmError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={resetConfirmState}>
|
|
||||||
Hủy
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleConfirmSend}>Xác nhận gửi</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Dialog for add/edit */}
|
{/* Dialog for add/edit */}
|
||||||
{formContent && (
|
{formContent && (
|
||||||
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={onDialogOpen}>
|
||||||
|
|
|
||||||
|
|
@ -1,277 +0,0 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { DeviceOverviewCard } from "@/components/cards/device-overview-card";
|
|
||||||
import { RoomManagementCard } from "@/components/cards/room-management-card";
|
|
||||||
import { SoftwareDistributionCard } from "@/components/cards/software-distribution-card";
|
|
||||||
import { RefreshCw } from "lucide-react";
|
|
||||||
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
|
||||||
import type {
|
|
||||||
DashboardGeneralInfo,
|
|
||||||
DeviceOverviewResponse,
|
|
||||||
RoomManagementResponse,
|
|
||||||
RoomUsageResponse,
|
|
||||||
SoftwareDistributionResponse,
|
|
||||||
DeviceStatusByRoom,
|
|
||||||
} from "@/types/dashboard";
|
|
||||||
|
|
||||||
interface DashboardTemplateProps {
|
|
||||||
generalInfo?: DashboardGeneralInfo | null;
|
|
||||||
deviceOverview?: DeviceOverviewResponse | null;
|
|
||||||
roomManagement?: RoomManagementResponse | null;
|
|
||||||
roomUsage?: RoomUsageResponse | null;
|
|
||||||
softwareDistribution?: SoftwareDistributionResponse | null;
|
|
||||||
devicesByRoom?: DeviceStatusByRoom[] | null;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isFetching?: boolean;
|
|
||||||
onRefresh?: () => void | Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardTemplate({
|
|
||||||
generalInfo,
|
|
||||||
deviceOverview,
|
|
||||||
roomManagement,
|
|
||||||
roomUsage,
|
|
||||||
softwareDistribution,
|
|
||||||
devicesByRoom,
|
|
||||||
isLoading = false,
|
|
||||||
isFetching = false,
|
|
||||||
onRefresh,
|
|
||||||
}: DashboardTemplateProps) {
|
|
||||||
const [showAllRooms, setShowAllRooms] = useState(false);
|
|
||||||
const [usageRange, setUsageRange] = useState<"weekly" | "monthly">("weekly");
|
|
||||||
const totalDevices = generalInfo?.totalDevices ?? deviceOverview?.totalDevices ?? 0;
|
|
||||||
const onlineDevices = generalInfo?.onlineDevices ?? deviceOverview?.onlineDevices ?? 0;
|
|
||||||
const offlineDevices = generalInfo?.offlineDevices ?? deviceOverview?.offlineDevices ?? 0;
|
|
||||||
const totalRooms = generalInfo?.totalRooms ?? roomManagement?.totalRooms ?? 0;
|
|
||||||
const roomsNeedAttentionCount = roomManagement?.roomsNeedAttention?.length ?? 0;
|
|
||||||
const onlineRate = totalDevices > 0 ? Math.round((onlineDevices / totalDevices) * 100) : 0;
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{
|
|
||||||
label: "Tổng thiết bị",
|
|
||||||
value: totalDevices,
|
|
||||||
note: `Online ${onlineRate}%`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Đang online",
|
|
||||||
value: onlineDevices,
|
|
||||||
note: "Thiết bị kết nối",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Đang offline",
|
|
||||||
value: offlineDevices,
|
|
||||||
note: "Cần kiểm tra",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Tổng phòng",
|
|
||||||
value: totalRooms,
|
|
||||||
note: `${roomsNeedAttentionCount} phòng cần chú ý`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getRoomPercent = (room: DeviceStatusByRoom) => {
|
|
||||||
if (typeof room.onlinePercentage === "number") return room.onlinePercentage;
|
|
||||||
if (!room.totalDevices) return 0;
|
|
||||||
return (room.onlineDevices / room.totalDevices) * 100;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedRooms = useMemo(() => {
|
|
||||||
return (devicesByRoom ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => getRoomPercent(a) - getRoomPercent(b));
|
|
||||||
}, [devicesByRoom]);
|
|
||||||
|
|
||||||
const roomSnapshot = showAllRooms ? sortedRooms : sortedRooms.slice(0, 6);
|
|
||||||
|
|
||||||
const usageSeries = useMemo(() => {
|
|
||||||
const usageRooms = roomUsage?.rooms ?? [];
|
|
||||||
const usageKey = usageRange === "weekly" ? "weekly" : "monthly";
|
|
||||||
const aggregated = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const room of usageRooms) {
|
|
||||||
for (const point of room[usageKey]) {
|
|
||||||
aggregated.set(
|
|
||||||
point.date,
|
|
||||||
(aggregated.get(point.date) ?? 0) + point.onlineDevices
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(aggregated.entries())
|
|
||||||
.map(([date, value]) => ({
|
|
||||||
date,
|
|
||||||
value,
|
|
||||||
label: new Date(date).toLocaleDateString("vi-VN", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
||||||
}, [roomUsage, usageRange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dashboard-scope relative w-full px-6 py-8">
|
|
||||||
<div className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-br from-slate-50 via-white to-emerald-50" />
|
|
||||||
<div className="pointer-events-none absolute -top-20 right-10 -z-10 h-72 w-72 rounded-full bg-emerald-200/40 blur-3xl" />
|
|
||||||
<div className="pointer-events-none absolute top-32 -left-24 -z-10 h-72 w-72 rounded-full bg-sky-200/40 blur-3xl" />
|
|
||||||
<div className="pointer-events-none absolute bottom-0 right-1/3 -z-10 h-64 w-64 rounded-full bg-amber-100/40 blur-3xl" />
|
|
||||||
|
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Bảng điều khiển</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Tổng hợp sức khỏe thiết bị, phòng và phân phối phần mềm trong thời gian thực.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
|
|
||||||
Cần chú ý: {roomsNeedAttentionCount} phòng
|
|
||||||
</Badge>
|
|
||||||
<Button onClick={() => onRefresh?.()} disabled={isFetching} className="gap-2">
|
|
||||||
<RefreshCw className={isFetching ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
|
||||||
{isFetching ? "Đang làm mới" : "Làm mới"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
||||||
{stats.map((stat) => (
|
|
||||||
<div
|
|
||||||
key={stat.label}
|
|
||||||
className="rounded-2xl border border-muted/60 bg-white/80 p-4 shadow-sm backdrop-blur"
|
|
||||||
>
|
|
||||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">{stat.label}</div>
|
|
||||||
<div className="mt-2 flex items-end justify-between">
|
|
||||||
<div className="text-3xl font-semibold">{stat.value}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{stat.note}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-3 animate-in fade-in slide-in-from-bottom-6 duration-700">
|
|
||||||
<div className="flex flex-col gap-6 xl:col-span-2">
|
|
||||||
<DeviceOverviewCard data={deviceOverview ?? null} isLoading={!!isLoading} />
|
|
||||||
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Tần suất sử dụng</CardTitle>
|
|
||||||
<CardDescription>Tổng thiết bị online theo ngày</CardDescription>
|
|
||||||
<CardAction>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={usageRange === "weekly" ? "default" : "outline"}
|
|
||||||
onClick={() => setUsageRange("weekly")}
|
|
||||||
>
|
|
||||||
7 ngày
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={usageRange === "monthly" ? "default" : "outline"}
|
|
||||||
onClick={() => setUsageRange("monthly")}
|
|
||||||
>
|
|
||||||
30 ngày
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardAction>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{usageSeries.length > 0 ? (
|
|
||||||
<div className="h-56">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<AreaChart data={usageSeries} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="usageGradient" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.4} />
|
|
||||||
<stop offset="95%" stopColor="#0ea5e9" stopOpacity={0.05} />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value) => [value ?? 0, "Online"]}
|
|
||||||
labelFormatter={(label) => `Ngày ${label}`}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
type="monotone"
|
|
||||||
dataKey="value"
|
|
||||||
stroke="#0ea5e9"
|
|
||||||
fill="url(#usageGradient)"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">Chưa có dữ liệu sử dụng</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<RoomManagementCard data={roomManagement ?? null} isLoading={!!isLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<SoftwareDistributionCard data={softwareDistribution ?? null} isLoading={!!isLoading} />
|
|
||||||
|
|
||||||
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Tình trạng theo phòng</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Online / Tổng thiết bị từng phòng ({sortedRooms.length} phòng)
|
|
||||||
</CardDescription>
|
|
||||||
{sortedRooms.length > 6 && (
|
|
||||||
<CardAction>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowAllRooms((value) => !value)}
|
|
||||||
>
|
|
||||||
{showAllRooms ? "Thu gọn" : "Xem tất cả"}
|
|
||||||
</Button>
|
|
||||||
</CardAction>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className={showAllRooms ? "space-y-4 max-h-[480px] overflow-auto pr-2" : "space-y-4"}>
|
|
||||||
{roomSnapshot.length > 0 ? (
|
|
||||||
roomSnapshot.map((room) => {
|
|
||||||
const percentRaw = getRoomPercent(room);
|
|
||||||
const percent = Math.max(0, Math.min(100, Math.round(percentRaw)));
|
|
||||||
return (
|
|
||||||
<div key={room.roomName} className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<div className="font-medium">{room.roomName}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{room.onlineDevices}/{room.totalDevices}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-2 flex-1 rounded-full bg-muted/40">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-emerald-500"
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-10 text-right text-xs font-medium">{percent}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground">Chưa có dữ liệu phòng</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { AppWindow, Building, CircleX, ClipboardList, Folder, Home, Monitor, ShieldCheck, Terminal, UserPlus} from "lucide-react";
|
import { AppWindow, Building, CircleX, Folder, Home, ShieldCheck, Terminal, UserPlus} from "lucide-react";
|
||||||
import { PermissionEnum } from "./permission";
|
import { PermissionEnum } from "./permission";
|
||||||
|
|
||||||
enum AppSidebarSectionCode {
|
enum AppSidebarSectionCode {
|
||||||
|
|
@ -12,7 +12,6 @@ enum AppSidebarSectionCode {
|
||||||
LIST_ROLES = 8,
|
LIST_ROLES = 8,
|
||||||
LIST_PERMISSIONS = 9,
|
LIST_PERMISSIONS = 9,
|
||||||
LIST_USERS = 10,
|
LIST_USERS = 10,
|
||||||
REMOTE_LIVE_CONTROL = 11,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const appSidebarSection = {
|
export const appSidebarSection = {
|
||||||
|
|
@ -27,7 +26,7 @@ export const appSidebarSection = {
|
||||||
code: AppSidebarSectionCode.DASHBOARD,
|
code: AppSidebarSectionCode.DASHBOARD,
|
||||||
icon: Home,
|
icon: Home,
|
||||||
permissions: [PermissionEnum.ALLOW_ALL],
|
permissions: [PermissionEnum.ALLOW_ALL],
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -40,13 +39,6 @@ export const appSidebarSection = {
|
||||||
icon: Building,
|
icon: Building,
|
||||||
permissions: [PermissionEnum.VIEW_ROOM],
|
permissions: [PermissionEnum.VIEW_ROOM],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Điều khiển trực tiếp",
|
|
||||||
url: "/remote-control",
|
|
||||||
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
|
|
||||||
icon: Monitor,
|
|
||||||
permissions: [PermissionEnum.VIEW_REMOTE_CONTROL],
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -101,17 +93,6 @@ export const appSidebarSection = {
|
||||||
permissions: [PermissionEnum.VIEW_USER],
|
permissions: [PermissionEnum.VIEW_USER],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Audits",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "Lịch sử hoạt động",
|
|
||||||
url: "/audits",
|
|
||||||
icon: ClipboardList,
|
|
||||||
permissions: [PermissionEnum.VIEW_AUDIT_LOGS],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
export interface Audits {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
dateTime: string; // ISO string
|
|
||||||
|
|
||||||
// request identity
|
|
||||||
apiCall?: string; // Controller.ActionName
|
|
||||||
url?: string;
|
|
||||||
requestPayload?: string; // request body (redacted)
|
|
||||||
|
|
||||||
// DB fields — null if request didn't touch DB
|
|
||||||
action?: string;
|
|
||||||
tableName?: string;
|
|
||||||
entityId?: string;
|
|
||||||
oldValues?: string;
|
|
||||||
newValues?: string;
|
|
||||||
|
|
||||||
// result
|
|
||||||
isSuccess?: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PageResult<T> {
|
|
||||||
items: T[];
|
|
||||||
totalCount: number;
|
|
||||||
pageNumber: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@ export interface CommandRegistry {
|
||||||
commandContent: string;
|
commandContent: string;
|
||||||
qoS: 0 | 1 | 2;
|
qoS: 0 | 1 | 2;
|
||||||
isRetained: boolean;
|
isRetained: boolean;
|
||||||
ttlMinutes: number;
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
export interface RecentOfflineDevice {
|
|
||||||
deviceId: string;
|
|
||||||
room?: string | null;
|
|
||||||
lastSeen?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceOverviewResponse {
|
|
||||||
totalDevices: number;
|
|
||||||
onlineDevices: number;
|
|
||||||
offlineDevices: number;
|
|
||||||
onlinePercentage?: number;
|
|
||||||
devicesWithOutdatedVersion: number;
|
|
||||||
recentOfflineDevices: RecentOfflineDevice[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeviceStatusByRoom {
|
|
||||||
roomName: string;
|
|
||||||
totalDevices: number;
|
|
||||||
onlineDevices: number;
|
|
||||||
offlineDevices: number;
|
|
||||||
onlinePercentage?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardGeneralInfo {
|
|
||||||
totalDevices: number;
|
|
||||||
totalRooms: number;
|
|
||||||
onlineDevices: number;
|
|
||||||
offlineDevices: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomUsagePoint {
|
|
||||||
date: string;
|
|
||||||
onlineDevices: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomUsageItem {
|
|
||||||
roomName: string;
|
|
||||||
weekly: RoomUsagePoint[];
|
|
||||||
monthly: RoomUsagePoint[];
|
|
||||||
weeklyTotalOnlineDevices: number;
|
|
||||||
monthlyTotalOnlineDevices: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomUsageResponse {
|
|
||||||
weekFrom: string;
|
|
||||||
weekTo: string;
|
|
||||||
monthFrom: string;
|
|
||||||
monthTo: string;
|
|
||||||
rooms: RoomUsageItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomHealthStatus {
|
|
||||||
roomName: string;
|
|
||||||
totalDevices: number;
|
|
||||||
onlineDevices: number;
|
|
||||||
offlineDevices: number;
|
|
||||||
healthPercentage?: number;
|
|
||||||
healthStatus?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RoomManagementResponse {
|
|
||||||
totalRooms: number;
|
|
||||||
rooms: RoomHealthStatus[];
|
|
||||||
roomsNeedAttention: RoomHealthStatus[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentVersionStats {
|
|
||||||
latestVersion: string;
|
|
||||||
devicesWithLatestVersion: number;
|
|
||||||
devicesWithOldVersion: number;
|
|
||||||
updateCoverage?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TopFailedSoftware {
|
|
||||||
fileName: string;
|
|
||||||
failCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentInstallActivity {
|
|
||||||
deviceId: string;
|
|
||||||
fileName: string;
|
|
||||||
status: string;
|
|
||||||
timestamp: string;
|
|
||||||
message?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SoftwareDistributionResponse {
|
|
||||||
totalInstallations: number;
|
|
||||||
successfulInstallations: number;
|
|
||||||
failedInstallations: number;
|
|
||||||
pendingInstallations: number;
|
|
||||||
successRate?: number;
|
|
||||||
agentVersionStats: AgentVersionStats;
|
|
||||||
topFailedSoftware: TopFailedSoftware[];
|
|
||||||
recentActivities: RecentInstallActivity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardSummaryResponse {
|
|
||||||
generalInfo: DashboardGeneralInfo;
|
|
||||||
roomUsage: RoomUsageResponse;
|
|
||||||
deviceOverview: DeviceOverviewResponse;
|
|
||||||
roomManagement: RoomManagementResponse;
|
|
||||||
softwareDistribution: SoftwareDistributionResponse;
|
|
||||||
generatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,6 @@ export type Version = {
|
||||||
version: string;
|
version: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
syncFolder?: string;
|
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
requestUpdateAt?: string;
|
requestUpdateAt?: string;
|
||||||
isRequired: boolean;
|
isRequired: boolean;
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ export enum PermissionEnum {
|
||||||
EDIT_COMMAND = 53,
|
EDIT_COMMAND = 53,
|
||||||
DEL_COMMAND = 54,
|
DEL_COMMAND = 54,
|
||||||
SEND_COMMAND = 55,
|
SEND_COMMAND = 55,
|
||||||
SEND_SENSITIVE_COMMAND = 56,
|
|
||||||
|
|
||||||
//DEVICE_OPERATION
|
//DEVICE_OPERATION
|
||||||
DEVICE_OPERATION = 70,
|
DEVICE_OPERATION = 70,
|
||||||
|
|
@ -60,12 +59,10 @@ export enum PermissionEnum {
|
||||||
VIEW_ACCOUNT_ROOM = 115,
|
VIEW_ACCOUNT_ROOM = 115,
|
||||||
EDIT_ACCOUNT_ROOM = 116,
|
EDIT_ACCOUNT_ROOM = 116,
|
||||||
|
|
||||||
|
|
||||||
//WARNING_OPERATION
|
//WARNING_OPERATION
|
||||||
WARNING_OPERATION = 140,
|
WARNING_OPERATION = 140,
|
||||||
VIEW_WARNING = 141,
|
VIEW_WARNING = 141,
|
||||||
|
|
||||||
|
|
||||||
//USER_OPERATION
|
//USER_OPERATION
|
||||||
USER_OPERATION = 150,
|
USER_OPERATION = 150,
|
||||||
VIEW_USER_ROLE = 151,
|
VIEW_USER_ROLE = 151,
|
||||||
|
|
@ -83,7 +80,7 @@ export enum PermissionEnum {
|
||||||
DEL_ROLE = 164,
|
DEL_ROLE = 164,
|
||||||
|
|
||||||
// AGENT
|
// AGENT
|
||||||
AGENT_OPERATION = 170,
|
APP_OPERATION = 170,
|
||||||
VIEW_AGENT = 171,
|
VIEW_AGENT = 171,
|
||||||
UPDATE_AGENT = 173,
|
UPDATE_AGENT = 173,
|
||||||
SEND_UPDATE_COMMAND = 174,
|
SEND_UPDATE_COMMAND = 174,
|
||||||
|
|
@ -97,18 +94,9 @@ export enum PermissionEnum {
|
||||||
ADD_APP_TO_SELECTED = 185,
|
ADD_APP_TO_SELECTED = 185,
|
||||||
DEL_APP_FROM_SELECTED = 186,
|
DEL_APP_FROM_SELECTED = 186,
|
||||||
|
|
||||||
// AUDIT
|
|
||||||
AUDIT_OPERATION = 190,
|
|
||||||
VIEW_AUDIT_LOGS = 191,
|
|
||||||
|
|
||||||
//REMOTE CONTROL
|
|
||||||
REMOTE_CONTROL_OPERATION = 200,
|
|
||||||
VIEW_REMOTE_CONTROL = 201,
|
|
||||||
CONTROL_REMOTE = 202,
|
|
||||||
|
|
||||||
//Undefined
|
//Undefined
|
||||||
UNDEFINED = 9999,
|
UNDEFINED = 9999,
|
||||||
|
|
||||||
//Allow All
|
//Allow All
|
||||||
ALLOW_ALL = 0
|
ALLOW_ALL = 0,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,11 @@
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
userId?: number;
|
|
||||||
userName: string;
|
userName: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
roleId: number;
|
roleId: number;
|
||||||
accessRooms: string[];
|
accessRooms: number[];
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
createdBy?: string | null;
|
createdBy?: string | null;
|
||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
updatedBy?: string | null;
|
updatedBy?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateUserInfoRequest = {
|
|
||||||
name: string;
|
|
||||||
userName: string;
|
|
||||||
accessRooms?: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateUserRoleRequest = {
|
|
||||||
roleId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateUserInfoResponse = {
|
|
||||||
userId: number;
|
|
||||||
userName: string;
|
|
||||||
name: string;
|
|
||||||
roleId: number;
|
|
||||||
accessRooms: number[];
|
|
||||||
updatedAt?: string | null;
|
|
||||||
updatedBy?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateUserRoleResponse = {
|
|
||||||
userId: number;
|
|
||||||
userName: string;
|
|
||||||
roleId: number;
|
|
||||||
roleName?: string | null;
|
|
||||||
updatedAt?: string | null;
|
|
||||||
updatedBy?: string | null;
|
|
||||||
};
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import react from '@vitejs/plugin-react'
|
||||||
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import basicSsl from '@vitejs/plugin-basic-ssl'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
|
@ -16,8 +15,7 @@ export default defineConfig({
|
||||||
}),
|
}),
|
||||||
|
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss()
|
||||||
basicSsl()
|
|
||||||
// ...,
|
// ...,
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|
@ -25,28 +23,4 @@ export default defineConfig({
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/mesh-api': {
|
|
||||||
target: 'https://my-mesh-test.com',
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path.replace(/^\/mesh-api/, ''),
|
|
||||||
secure: false, // Bỏ qua lỗi SSL của MeshCentral
|
|
||||||
configure: (proxy, options) => {
|
|
||||||
proxy.on('proxyRes', (proxyRes) => {
|
|
||||||
const setCookie = proxyRes.headers['set-cookie'];
|
|
||||||
if (setCookie) {
|
|
||||||
// Sửa toàn bộ Cookie trả về: Đổi Lax -> None, thêm Secure
|
|
||||||
proxyRes.headers['set-cookie'] = setCookie.map(cookie => {
|
|
||||||
// Nếu gặp cookie trống (e30=), ta có thể bỏ qua hoặc giữ nhưng phải ép None
|
|
||||||
return cookie
|
|
||||||
.replace(/SameSite=Lax/gi, 'SameSite=None')
|
|
||||||
.replace(/SameSite=Strict/gi, 'SameSite=None') + '; Secure';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
Loading…
Reference in New Issue
Block a user