Compare commits

..

2 Commits

19 changed files with 730 additions and 101 deletions

74
Users-API.md Normal file
View File

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

View File

@ -22,7 +22,7 @@ export function RoomManagementCard({
<Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm"> <Card className="bg-white/80 backdrop-blur border-muted/60 shadow-sm">
<CardHeader> <CardHeader>
<CardTitle>Quản phòng</CardTitle> <CardTitle>Quản phòng</CardTitle>
<CardDescription>Thông tin tổng quan phòng cần chú ý</CardDescription> <CardDescription>Thông tin tổng quan các phòng đang không sử dụng</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -47,7 +47,7 @@ export function RoomManagementCard({
</div> </div>
<div className="mt-4"> <div className="mt-4">
<div className="text-sm font-medium">Phòng cần chú ý</div> <div className="text-sm font-medium">Phòng không dùng</div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? ( {data?.roomsNeedAttention && data.roomsNeedAttention.length > 0 ? (
data.roomsNeedAttention.map((r: RoomHealthStatus) => ( data.roomsNeedAttention.map((r: RoomHealthStatus) => (

View File

@ -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 { useState, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
export interface SelectItem { export interface SelectItem {
label: string; label: string;
@ -16,6 +16,7 @@ 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;
} }
@ -26,11 +27,18 @@ 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())

View File

@ -21,6 +21,10 @@ 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`,
@ -38,7 +42,7 @@ export const API_ENDPOINTS = {
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: `${BASE_URL}/AppVersion/requirefile/delete`,
DELETE_FILES: (fileId: number) => `${BASE_URL}/AppVersion/delete/${fileId}`, DELETE_FILES: `${BASE_URL}/AppVersion/delete`,
}, },
DEVICE_COMM: { DEVICE_COMM: {
DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`, DOWNLOAD_FILES: (roomName: string) => `${BASE_URL}/DeviceComm/downloadfile/${roomName}`,

View File

@ -176,7 +176,7 @@ export function useDeleteFile() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (fileId: number) => appVersionService.deleteFile(fileId), mutationFn: (data: { MsiFileIds: number[] }) => appVersionService.deleteFile(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: APP_VERSION_QUERY_KEYS.softwareList(), queryKey: APP_VERSION_QUERY_KEYS.softwareList(),

View File

@ -1,6 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as userService from "@/services/user.service"; import * as userService from "@/services/user.service";
import type { UserProfile } from "@/types/user-profile"; import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
} from "@/types/user-profile";
const USER_QUERY_KEYS = { const USER_QUERY_KEYS = {
all: ["users"] as const, all: ["users"] as const,
@ -18,3 +22,47 @@ 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(),
});
},
});
}

View File

@ -30,6 +30,7 @@ import { Route as AuthProfileChangePasswordIndexRouteImport } from './routes/_au
import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index' import { Route as AuthProfileUserNameIndexRouteImport } from './routes/_auth/profile/$userName/index'
import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/callback/index' import { Route as authSsoCallbackIndexRouteImport } from './routes/(auth)/sso/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 AuthRoomsRoomNameConnectIndexRouteImport } from './routes/_auth/rooms/$roomName/connect/index'
@ -141,6 +142,12 @@ const AuthUserRoleRoleIdIndexRoute = AuthUserRoleRoleIdIndexRouteImport.update({
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/',
@ -189,6 +196,7 @@ export interface FileRoutesByFullPath {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute '/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 {
@ -215,6 +223,7 @@ export interface FileRoutesByTo {
'/rooms/$roomName/connect': typeof AuthRoomsRoomNameConnectIndexRoute '/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 {
@ -243,6 +252,7 @@ export interface FileRoutesById {
'/_auth/rooms/$roomName/connect/': typeof AuthRoomsRoomNameConnectIndexRoute '/_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 {
@ -271,6 +281,7 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect' | '/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:
@ -297,6 +308,7 @@ export interface FileRouteTypes {
| '/rooms/$roomName/connect' | '/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__'
@ -324,6 +336,7 @@ export interface FileRouteTypes {
| '/_auth/rooms/$roomName/connect/' | '/_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
} }
@ -483,6 +496,13 @@ 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'
@ -535,6 +555,7 @@ interface AuthRouteChildren {
AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute AuthRoomsRoomNameConnectIndexRoute: typeof AuthRoomsRoomNameConnectIndexRoute
AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute AuthRoomsRoomNameFolderStatusIndexRoute: typeof AuthRoomsRoomNameFolderStatusIndexRoute
AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute AuthUserChangePasswordUserNameIndexRoute: typeof AuthUserChangePasswordUserNameIndexRoute
AuthUserEditUserNameIndexRoute: typeof AuthUserEditUserNameIndexRoute
AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute AuthUserRoleRoleIdIndexRoute: typeof AuthUserRoleRoleIdIndexRoute
} }
@ -561,6 +582,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthRoomsRoomNameFolderStatusIndexRoute, AuthRoomsRoomNameFolderStatusIndexRoute,
AuthUserChangePasswordUserNameIndexRoute: AuthUserChangePasswordUserNameIndexRoute:
AuthUserChangePasswordUserNameIndexRoute, AuthUserChangePasswordUserNameIndexRoute,
AuthUserEditUserNameIndexRoute: AuthUserEditUserNameIndexRoute,
AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute, AuthUserRoleRoleIdIndexRoute: AuthUserRoleRoleIdIndexRoute,
} }

View File

@ -137,11 +137,10 @@ function AppsComponent() {
return; return;
} }
const MsiFileIds = selectedRows.map((row: any) => row.original.id);
try { try {
for (const row of selectedRows) { await deleteMutation.mutateAsync({ MsiFileIds });
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!");
@ -175,12 +174,10 @@ 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 {
for (const row of selectedRows) { await deleteMutation.mutateAsync({ MsiFileIds });
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({});

View File

@ -9,6 +9,9 @@ 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 = [

View File

@ -22,6 +22,9 @@ 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 = [
@ -59,7 +62,8 @@ 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 = "Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)"; newErrors.userName =
"Tên đăng nhập chỉ cho phép chữ cái, số, dấu chấm và gạch dưới (3-20 ký tự)";
} }
// Validate name // Validate name
@ -106,7 +110,8 @@ 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 = error.response?.data?.message || "Tạo tài khoản thất bại!"; const errorMessage =
error.response?.data?.message || "Tạo tài khoản thất bại!";
toast.error(errorMessage); toast.error(errorMessage);
} }
}; };
@ -128,15 +133,14 @@ 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">Tạo người dùng mới</h1> <h1 className="text-3xl font-bold tracking-tight">
Tạo người dùng mới
</h1>
<p className="text-muted-foreground mt-1"> <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 <Button variant="outline" onClick={() => navigate({ to: "/user" })}>
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>
@ -164,7 +168,9 @@ function CreateUserComponent() {
<Input <Input
id="userName" id="userName"
value={formData.userName} value={formData.userName}
onChange={(e) => handleInputChange("userName", e.target.value)} onChange={(e) =>
handleInputChange("userName", e.target.value)
}
placeholder="Nhập tên đăng nhập (3-20 ký tự, chỉ chữ, số, . và _)" 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"
@ -202,7 +208,9 @@ function CreateUserComponent() {
id="password" id="password"
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)} onChange={(e) =>
handleInputChange("password", e.target.value)
}
placeholder="Nhập mật khẩu (tối thiểu 6 ký tự)" 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"
@ -220,13 +228,17 @@ function CreateUserComponent() {
id="confirmPassword" id="confirmPassword"
type="password" type="password"
value={formData.confirmPassword} value={formData.confirmPassword}
onChange={(e) => handleInputChange("confirmPassword", e.target.value)} onChange={(e) =>
handleInputChange("confirmPassword", e.target.value)
}
placeholder="Nhập lại mật khẩu" 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">{errors.confirmPassword}</p> <p className="text-sm text-destructive">
{errors.confirmPassword}
</p>
)} )}
</div> </div>
</div> </div>
@ -288,8 +300,8 @@ function CreateUserComponent() {
> >
Hủy Hủy
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={createMutation.isPending} disabled={createMutation.isPending}
className="min-w-[140px]" className="min-w-[140px]"
> >

View File

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

View File

@ -10,10 +10,13 @@ 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, Trash2, Shield } from "lucide-react"; import { Edit2, Settings, Shield, Trash2 } 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 = [
@ -65,21 +68,6 @@ 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: () => (
@ -87,42 +75,78 @@ function RouteComponent() {
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-2 justify-center items-center"> <div className="flex gap-2 justify-center items-center">
<Button <Tooltip>
size="sm" <TooltipTrigger asChild>
variant="ghost" <Button
onClick={(e) => { size="sm"
e.stopPropagation(); variant="ghost"
navigate({ onClick={(e) => {
to: "/user/change-password/$userName", e.stopPropagation();
params: { userName: row.original.userName }, navigate({
} as any); to: "/user/edit/$userName",
}} params: { userName: row.original.userName },
> } as any);
<Edit2 className="h-4 w-4" /> }}
</Button> >
<Button <Settings className="h-4 w-4" />
size="sm" </Button>
variant="ghost" </TooltipTrigger>
onClick={(e) => { <TooltipContent side="top">Đi thông tin</TooltipContent>
e.stopPropagation(); </Tooltip>
navigate({ to: "/user/role/$roleId", params: { roleId: String(row.original.roleId) } } as any); <Tooltip>
}} <TooltipTrigger asChild>
> <Button
<Shield className="h-4 w-4" /> size="sm"
</Button> variant="ghost"
<Button onClick={(e) => {
size="sm" e.stopPropagation();
variant="ghost" navigate({
onClick={async (e) => { to: "/user/change-password/$userName",
e.stopPropagation(); params: { userName: row.original.userName },
if (!confirm("Bạn có chắc muốn xóa người dùng này?")) return; } as any);
// 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({}); <Edit2 className="h-4 w-4" />
}} </Button>
> </TooltipTrigger>
<Trash2 className="h-4 w-4 text-red-500" /> <TooltipContent side="top">Đi mật khẩu</TooltipContent>
</Button> </Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
navigate({
to: "/user/role/$roleId",
params: { roleId: String(row.original.roleId) },
} as any);
}}
>
<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,

View File

@ -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 | AccessControl" }] meta: [{ title: "Quyền của người dùng" }]
}), }),
component: ViewRolePermissionsComponent, component: ViewRolePermissionsComponent,
loader: async ({ context, params }) => { loader: async ({ context, params }) => {

View File

@ -120,9 +120,12 @@ export async function deleteRequiredFile(data: { MsiFileIds: number[] }): Promis
/** /**
* Xóa file từ server * Xóa file từ server
* @param fileId - ID file * @param data - DownloadMsiRequest { MsiFileIds: number[] }
*/ */
export async function deleteFile(fileId: number): Promise<{ message: string }> { export async function deleteFile(data: { MsiFileIds: number[] }): Promise<{ message: string }> {
const response = await axios.delete(API_ENDPOINTS.APP_VERSION.DELETE_FILES(fileId)); const response = await axios.delete(
API_ENDPOINTS.APP_VERSION.DELETE_FILES,
{ data }
);
return response.data; return response.data;
} }

View File

@ -1,6 +1,20 @@
import axios from "@/config/axios"; import axios from "@/config/axios";
import { API_ENDPOINTS } from "@/config/api"; import { API_ENDPOINTS } from "@/config/api";
import type { UserProfile } from "@/types/user-profile"; import type {
UserProfile,
UpdateUserInfoRequest,
UpdateUserRoleRequest,
UpdateUserInfoResponse,
UpdateUserRoleResponse,
} from "@/types/user-profile";
// Helper to extract data from wrapped or unwrapped response
function extractData<T>(responseData: any): T {
if (responseData && typeof responseData === "object" && "success" in responseData && "data" in responseData) {
return responseData.data as T;
}
return responseData as T;
}
/** /**
* Lấy danh sách thông tin người dùng chuyển sang camelCase keys * Lấy danh sách thông tin người dùng chuyển sang camelCase keys
@ -11,6 +25,7 @@ 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 ?? "",
@ -31,4 +46,32 @@ 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 };

View File

@ -168,7 +168,7 @@ export function DashboardTemplate({
variant={usageRange === "weekly" ? "default" : "outline"} variant={usageRange === "weekly" ? "default" : "outline"}
onClick={() => setUsageRange("weekly")} onClick={() => setUsageRange("weekly")}
> >
7 ngay 7 ngày
</Button> </Button>
<Button <Button
type="button" type="button"
@ -176,7 +176,7 @@ export function DashboardTemplate({
variant={usageRange === "monthly" ? "default" : "outline"} variant={usageRange === "monthly" ? "default" : "outline"}
onClick={() => setUsageRange("monthly")} onClick={() => setUsageRange("monthly")}
> >
30 ngay 30 ngày
</Button> </Button>
</div> </div>
</CardAction> </CardAction>

View File

@ -27,7 +27,7 @@ export const appSidebarSection = {
code: AppSidebarSectionCode.DASHBOARD, code: AppSidebarSectionCode.DASHBOARD,
icon: Home, icon: Home,
permissions: [PermissionEnum.ALLOW_ALL], permissions: [PermissionEnum.ALLOW_ALL],
}, }
], ],
}, },
{ {
@ -40,6 +40,13 @@ 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],
}
], ],
}, },
{ {
@ -95,18 +102,6 @@ export const appSidebarSection = {
} }
] ]
}, },
{
title: "Điều khiển từ xa",
items: [
{
title: "Điều khiển trực tiếp",
url: "/remote-control",
code: AppSidebarSectionCode.REMOTE_LIVE_CONTROL,
icon: Monitor,
permissions: [PermissionEnum.ALLOW_ALL],
}
]
},
{ {
title: "Audits", title: "Audits",
items: [ items: [

View File

@ -101,6 +101,11 @@ export enum PermissionEnum {
AUDIT_OPERATION = 190, AUDIT_OPERATION = 190,
VIEW_AUDIT_LOGS = 191, VIEW_AUDIT_LOGS = 191,
//REMOTE CONTROL
REMOTE_CONTROL_OPERATION = 200,
VIEW_REMOTE_CONTROL = 201,
CONTROL_REMOTE = 202,
//Undefined //Undefined
UNDEFINED = 9999, UNDEFINED = 9999,

View File

@ -1,4 +1,5 @@
export type UserProfile = { export type UserProfile = {
userId?: number;
userName: string; userName: string;
name: string; name: string;
role: string; role: string;
@ -8,4 +9,33 @@ export type UserProfile = {
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;
}; };