mirror of
https://github.com/kyantech/Palmr.git
synced 2025-10-22 22:02:00 +00:00
feat(users-management): add users management module
Introduce a new users management module that includes features for creating, reading, updating, and deleting users. The module includes components for user tables, modals for user actions, and hooks for managing user state and interactions. This replaces the previous admin page with a more comprehensive and modular approach to user management.
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
|
||||
function AdminPage() {
|
||||
return (
|
||||
<ProtectedRoute requireAdmin>
|
||||
<div>
|
||||
<h1>Admin Dashboard</h1>
|
||||
{/* Your admin content */}
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminPage;
|
@@ -68,7 +68,7 @@ export function PasswordForm({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button className="mt-4 font-semibold" variant="default" disabled={isSubmitting} type="submit">
|
||||
{!isSubmitting && <IconLock className="mr-2 h-4 w-4" />}
|
||||
{!isSubmitting && <IconLock className="h-4 w-4" />}
|
||||
{t("profile.password.updateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
@@ -48,7 +48,7 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
|
||||
: ""}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu >
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -61,12 +61,12 @@ export function ProfilePicture({ userData, onImageChange, onImageRemove }: Profi
|
||||
<DropdownMenuContent>
|
||||
{!!userData?.image ? (
|
||||
<DropdownMenuItem className="text-destructive" onClick={onImageRemove}>
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
<IconTrash className="h-4 w-4" />
|
||||
{t("profile.picture.removePhoto")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={handleAvatarClick}>
|
||||
<IconCamera className="mr-2 h-4 w-4" />
|
||||
<IconCamera className="h-4 w-4" />
|
||||
{t("profile.picture.uploadPhoto")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
@@ -0,0 +1,62 @@
|
||||
import { UserActionsDropdownProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
IconDotsVertical,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconBan
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
export function UserActionsDropdown({
|
||||
user,
|
||||
isCurrentUser,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: UserActionsDropdownProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={isCurrentUser ? "hidden" : ""}
|
||||
disabled={isCurrentUser}
|
||||
>
|
||||
<IconDotsVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onEdit(user)}>
|
||||
<IconEdit className="mr-2 h-4 w-4" />
|
||||
{t("users.actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onToggleStatus(user)}>
|
||||
{user.isActive ? (
|
||||
<IconBan className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<IconCheck className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{user.isActive ? t("users.actions.deactivate") : t("users.actions.activate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(user)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<IconTrash className="mr-2 h-4 w-4" />
|
||||
{t("users.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
@@ -0,0 +1,41 @@
|
||||
import { UserDeleteModalProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function UserDeleteModal({ isOpen, onClose, user, onConfirm }: UserDeleteModalProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="flex flex-col gap-1">
|
||||
{t("users.delete.title")}
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{user && (
|
||||
<p>
|
||||
{t("users.delete.confirmation", {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{t("users.delete.confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
142
apps/app/src/app/users-management/components/user-form-modal.tsx
Normal file
142
apps/app/src/app/users-management/components/user-form-modal.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { UserFormModalProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { IconDeviceFloppy } from "@tabler/icons-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { FormMessage } from "@/components/ui/form";
|
||||
|
||||
export function UserFormModal({ isOpen, onClose, modalMode, selectedUser, formMethods, onSubmit }: UserFormModalProps) {
|
||||
const t = useTranslations();
|
||||
const {
|
||||
register,
|
||||
formState: { errors, isSubmitting },
|
||||
} = formMethods;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<form onSubmit={formMethods.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
{modalMode === "create" ? t("users.form.titleCreate") : t("users.form.titleEdit")}
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.form.firstName")}</Label>
|
||||
<Input
|
||||
{...register("firstName")}
|
||||
className={errors.firstName ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<FormMessage>{errors.firstName.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.form.lastName")}</Label>
|
||||
<Input
|
||||
{...register("lastName")}
|
||||
className={errors.lastName ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<FormMessage>{errors.lastName.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.form.username")}</Label>
|
||||
<Input
|
||||
{...register("username")}
|
||||
className={errors.username ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.username && (
|
||||
<FormMessage>{errors.username.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.form.email")}</Label>
|
||||
<Input
|
||||
{...register("email")}
|
||||
type="email"
|
||||
className={errors.email ? "border-destructive" : ""}
|
||||
/>
|
||||
{errors.email && (
|
||||
<FormMessage>{errors.email.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{modalMode === "create"
|
||||
? t("users.form.password")
|
||||
: t("users.form.newPassword")}
|
||||
</Label>
|
||||
<Input
|
||||
{...register("password")}
|
||||
type="password"
|
||||
className={errors.password ? "border-destructive" : ""}
|
||||
placeholder={
|
||||
modalMode === "edit" ? t("users.form.passwordPlaceholder") : undefined
|
||||
}
|
||||
/>
|
||||
{errors.password && (
|
||||
<FormMessage>{errors.password.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalMode === "edit" && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("users.form.role")}</Label>
|
||||
<Select
|
||||
defaultValue={selectedUser?.isAdmin ? "true" : "false"}
|
||||
{...register("isAdmin")}
|
||||
>
|
||||
<SelectTrigger className={errors.isAdmin ? "border-destructive" : ""}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">{t("users.form.roleUser")}</SelectItem>
|
||||
<SelectItem value="true">{t("users.form.roleAdmin")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.isAdmin && (
|
||||
<FormMessage>{errors.isAdmin.message}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} type="button">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button disabled={isSubmitting} type="submit">
|
||||
{modalMode === "create" ? (
|
||||
""
|
||||
) : (
|
||||
<IconDeviceFloppy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{modalMode === "create" ? t("users.form.create") : t("users.form.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,42 @@
|
||||
import { UserManagementModalsProps } from "../types";
|
||||
import { UserDeleteModal } from "./user-delete-modal";
|
||||
import { UserFormModal } from "./user-form-modal";
|
||||
import { UserStatusModal } from "./user-status-modal";
|
||||
|
||||
export function UserManagementModals({
|
||||
modals,
|
||||
selectedUser,
|
||||
deleteModalUser,
|
||||
statusModalUser,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
formMethods,
|
||||
}: UserManagementModalsProps) {
|
||||
return (
|
||||
<>
|
||||
<UserFormModal
|
||||
formMethods={formMethods}
|
||||
isOpen={modals.isOpen}
|
||||
modalMode={modals.modalMode}
|
||||
selectedUser={selectedUser}
|
||||
onClose={modals.onClose}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
<UserDeleteModal
|
||||
isOpen={modals.isDeleteModalOpen}
|
||||
user={deleteModalUser}
|
||||
onClose={modals.onDeleteModalClose}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
|
||||
<UserStatusModal
|
||||
isOpen={modals.isStatusModalOpen}
|
||||
user={statusModalUser}
|
||||
onClose={modals.onStatusModalClose}
|
||||
onConfirm={onToggleStatus}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,46 @@
|
||||
import { UserStatusModalProps } from "../types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function UserStatusModal({ isOpen, onClose, user, onConfirm }: UserStatusModalProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader className="flex flex-col gap-1">
|
||||
{t("users.status.title")}
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{user && (
|
||||
<p>
|
||||
{t("users.status.confirmation", {
|
||||
action: user.isActive ? t("users.status.deactivate") : t("users.status.activate"),
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={user?.isActive ? "destructive" : "default"}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{user?.isActive ? t("users.status.deactivate") : t("users.status.activate")}{" "}
|
||||
{t("users.status.user")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -0,0 +1,54 @@
|
||||
import { UsersHeaderProps } from "../types";
|
||||
import Link from "next/link";
|
||||
import { IconLayoutDashboard, IconUsers, IconUserPlus } from "@tabler/icons-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function UsersHeader({ onCreateUser }: UsersHeaderProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUsers className="text-2xl" />
|
||||
<h1 className="text-2xl font-bold">{t("users.header.title")}</h1>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
onClick={onCreateUser}
|
||||
>
|
||||
<IconUserPlus size={18} className="mr-2" />
|
||||
{t("users.header.addUser")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/dashboard" className="flex items-center">
|
||||
<IconLayoutDashboard size={20} className="mr-2" />
|
||||
{t("common.dashboard")}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<span className="flex items-center gap-2">
|
||||
<IconUsers size={20} /> {t("users.header.management")}
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
);
|
||||
}
|
84
apps/app/src/app/users-management/components/users-table.tsx
Normal file
84
apps/app/src/app/users-management/components/users-table.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { UsersTableProps } from "../types";
|
||||
import { UserActionsDropdown } from "./user-actions-dropdown";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function UsersTable({ users, currentUser, onEdit, onDelete, onToggleStatus }: UsersTableProps) {
|
||||
const t = useTranslations();
|
||||
const isCurrentUser = (userId: string) => currentUser?.id === userId;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg shadow-sm overflow-hidden border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-b-0">
|
||||
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4">
|
||||
{t("users.table.user")}
|
||||
</TableHead>
|
||||
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4">
|
||||
{t("users.table.email")}
|
||||
</TableHead>
|
||||
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4">
|
||||
{t("users.table.status")}
|
||||
</TableHead>
|
||||
<TableHead className="h-10 text-xs font-bold text-muted-foreground bg-muted/50 px-4">
|
||||
{t("users.table.role")}
|
||||
</TableHead>
|
||||
<TableHead className="h-10 w-[70px] text-xs font-bold text-muted-foreground bg-muted/50 px-4">
|
||||
{t("users.table.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-muted/50 transition-colors border-0">
|
||||
<TableCell className="h-12 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.image || ""} alt={user.username} />
|
||||
<AvatarFallback className="bg-primary/10 text-primary font-bold text-xl">
|
||||
{user.firstName[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium">{`${user.firstName} ${user.lastName}`}</p>
|
||||
<p className="text-sm text-muted-foreground">{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">{user.email}</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<Badge variant={user.isActive ? "default" : "destructive"}>
|
||||
{user.isActive ? t("users.table.active") : t("users.table.inactive")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<Badge variant={user.isAdmin ? "destructive" : "secondary"}>
|
||||
{user.isAdmin ? t("users.table.admin") : t("users.table.userr")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-12 px-4">
|
||||
<UserActionsDropdown
|
||||
isCurrentUser={isCurrentUser(user.id)}
|
||||
user={user}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onToggleStatus={onToggleStatus}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
180
apps/app/src/app/users-management/hooks/use-user-management.ts
Normal file
180
apps/app/src/app/users-management/hooks/use-user-management.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client"
|
||||
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useDisclosure } from "@/hooks/use-disclosure";
|
||||
import { listUsers, registerUser, updateUser, deleteUser, activateUser, deactivateUser } from "@/http/endpoints";
|
||||
import type { ListUsers200Item } from "@/http/models";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Resolver, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const createSchemas = (t: (key: string) => string) => ({
|
||||
userSchema: z.object({
|
||||
firstName: z.string().min(1, t("validation.firstNameRequired")),
|
||||
lastName: z.string().min(1, t("validation.lastNameRequired")),
|
||||
username: z
|
||||
.string()
|
||||
.min(3, t("validation.usernameLength"))
|
||||
.regex(/^[^\s]+$/, t("validation.usernameSpaces")),
|
||||
email: z.string().email(t("validation.invalidEmail")),
|
||||
password: z.string().min(8, t("validation.passwordLength")).or(z.literal("")),
|
||||
isAdmin: z
|
||||
.union([z.enum(["true", "false"]), z.boolean()])
|
||||
.transform((val) => (typeof val === "string" ? val === "true" : val))
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UserFormData = z.infer<ReturnType<typeof createSchemas>["userSchema"]>;
|
||||
|
||||
export function useUserManagement() {
|
||||
const t = useTranslations();
|
||||
|
||||
const { userSchema } = createSchemas(t);
|
||||
const [users, setUsers] = useState<ListUsers200Item[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedUser, setSelectedUser] = useState<ListUsers200Item | null>(null);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [deleteModalUser, setDeleteModalUser] = useState<ListUsers200Item | null>(null);
|
||||
const [statusModalUser, setStatusModalUser] = useState<ListUsers200Item | null>(null);
|
||||
|
||||
const { user: currentUser } = useAuth();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isDeleteModalOpen, onOpen: onDeleteModalOpen, onClose: onDeleteModalClose } = useDisclosure();
|
||||
const { isOpen: isStatusModalOpen, onOpen: onStatusModalOpen, onClose: onStatusModalClose } = useDisclosure();
|
||||
|
||||
const formMethods = useForm<UserFormData>({
|
||||
resolver: zodResolver(userSchema) as Resolver<UserFormData>,
|
||||
});
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await listUsers();
|
||||
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.loadFailed"));
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const handleCreateUser = () => {
|
||||
setModalMode("create");
|
||||
setSelectedUser(null);
|
||||
formMethods.reset({});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleEditUser = (user: ListUsers200Item) => {
|
||||
setModalMode("edit");
|
||||
setSelectedUser(user);
|
||||
formMethods.reset({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isAdmin: user.isAdmin,
|
||||
password: "",
|
||||
});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const onSubmit = async (data: UserFormData) => {
|
||||
try {
|
||||
if (modalMode === "create") {
|
||||
await registerUser(data);
|
||||
toast.success(t("users.messages.createSuccess"));
|
||||
} else {
|
||||
if (!selectedUser) return;
|
||||
const updateData = {
|
||||
...data,
|
||||
id: selectedUser.id,
|
||||
} as { id: string } & Partial<typeof data>;
|
||||
|
||||
if (!data.password || data.password.trim() === "") {
|
||||
delete updateData.password;
|
||||
}
|
||||
|
||||
await updateUser(updateData);
|
||||
toast.success(t("users.messages.updateSuccess"));
|
||||
}
|
||||
onClose();
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.submitFailed", { mode: t(`users.modes.${modalMode}`) }));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async () => {
|
||||
if (!deleteModalUser) return;
|
||||
|
||||
try {
|
||||
await deleteUser(deleteModalUser.id);
|
||||
toast.success(t("users.messages.deleteSuccess"));
|
||||
loadUsers();
|
||||
onDeleteModalClose();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.deleteFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserStatus = async () => {
|
||||
if (!statusModalUser) return;
|
||||
|
||||
try {
|
||||
if (statusModalUser.isActive) {
|
||||
await deactivateUser(statusModalUser.id);
|
||||
toast.success(t("users.messages.deactivateSuccess"));
|
||||
} else {
|
||||
await activateUser(statusModalUser.id);
|
||||
toast.success(t("users.messages.activateSuccess"));
|
||||
}
|
||||
loadUsers();
|
||||
onStatusModalClose();
|
||||
} catch (error) {
|
||||
toast.error(t("users.errors.statusUpdateFailed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
users,
|
||||
isLoading,
|
||||
currentUser,
|
||||
selectedUser,
|
||||
deleteModalUser,
|
||||
statusModalUser,
|
||||
modals: {
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
modalMode,
|
||||
isDeleteModalOpen,
|
||||
onDeleteModalOpen,
|
||||
onDeleteModalClose,
|
||||
isStatusModalOpen,
|
||||
onStatusModalOpen,
|
||||
onStatusModalClose,
|
||||
setDeleteModalUser,
|
||||
setStatusModalUser,
|
||||
},
|
||||
handleCreateUser,
|
||||
handleEditUser,
|
||||
handleDeleteUser,
|
||||
handleToggleUserStatus,
|
||||
onSubmit,
|
||||
formMethods,
|
||||
};
|
||||
}
|
18
apps/app/src/app/users-management/layout.tsx
Normal file
18
apps/app/src/app/users-management/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translate = await getTranslations();
|
||||
|
||||
return {
|
||||
title: translate("settings.pageTitle"),
|
||||
};
|
||||
}
|
||||
|
||||
export default function UsersManagementLayout({ children }: LayoutProps) {
|
||||
return <>{children}</>;
|
||||
}
|
72
apps/app/src/app/users-management/page.tsx
Normal file
72
apps/app/src/app/users-management/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
import { UserManagementModals } from "./components/user-management-modals";
|
||||
import { UsersHeader } from "./components/users-header";
|
||||
import { UsersTable } from "./components/users-table";
|
||||
import { useUserManagement } from "./hooks/use-user-management";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { Navbar } from "@/components/layout/navbar";
|
||||
import { DefaultFooter } from "@/components/ui/default-footer";
|
||||
|
||||
export default function AdminAreaPage() {
|
||||
const {
|
||||
users,
|
||||
isLoading,
|
||||
currentUser,
|
||||
modals,
|
||||
selectedUser,
|
||||
deleteModalUser,
|
||||
statusModalUser,
|
||||
handleCreateUser,
|
||||
handleEditUser,
|
||||
handleDeleteUser,
|
||||
handleToggleUserStatus,
|
||||
onSubmit,
|
||||
formMethods,
|
||||
} = useUserManagement();
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute requireAdmin>
|
||||
|
||||
<div className="w-full h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<div className="flex-1 max-w-7xl mx-auto w-full py-8 px-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<UsersHeader onCreateUser={handleCreateUser} />
|
||||
|
||||
<UsersTable
|
||||
currentUser={currentUser}
|
||||
users={users}
|
||||
onDelete={(user) => {
|
||||
modals.setDeleteModalUser(user);
|
||||
modals.onDeleteModalOpen();
|
||||
}}
|
||||
onEdit={handleEditUser}
|
||||
onToggleStatus={(user) => {
|
||||
modals.setStatusModalUser(user);
|
||||
modals.onStatusModalOpen();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DefaultFooter />
|
||||
|
||||
<UserManagementModals
|
||||
deleteModalUser={deleteModalUser}
|
||||
formMethods={formMethods}
|
||||
modals={modals}
|
||||
selectedUser={selectedUser}
|
||||
statusModalUser={statusModalUser}
|
||||
onDelete={handleDeleteUser}
|
||||
onSubmit={onSubmit}
|
||||
onToggleStatus={handleToggleUserStatus}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
74
apps/app/src/app/users-management/types/index.ts
Normal file
74
apps/app/src/app/users-management/types/index.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { UserFormData } from "../hooks/use-user-management";
|
||||
import type { ListUsers200Item } from "@/http/models";
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
|
||||
export interface UserActionsDropdownProps {
|
||||
user: ListUsers200Item;
|
||||
isCurrentUser: boolean;
|
||||
onEdit: (user: ListUsers200Item) => void;
|
||||
onDelete: (user: ListUsers200Item) => void;
|
||||
onToggleStatus: (user: ListUsers200Item) => void;
|
||||
}
|
||||
|
||||
export interface UserDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: ListUsers200Item | null;
|
||||
onConfirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserFormModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modalMode: "create" | "edit";
|
||||
selectedUser: ListUsers200Item | null;
|
||||
formMethods: UseFormReturn<UserFormData>;
|
||||
onSubmit: (data: UserFormData) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UserManagementModalsProps {
|
||||
modals: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modalMode: "create" | "edit";
|
||||
isDeleteModalOpen: boolean;
|
||||
onDeleteModalClose: () => void;
|
||||
isStatusModalOpen: boolean;
|
||||
onStatusModalClose: () => void;
|
||||
};
|
||||
selectedUser: ListUsers200Item | null;
|
||||
deleteModalUser: ListUsers200Item | null;
|
||||
statusModalUser: ListUsers200Item | null;
|
||||
onSubmit: (data: UserFormData) => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
formMethods: UseFormReturn<UserFormData>;
|
||||
}
|
||||
|
||||
export interface UserStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: ListUsers200Item | null;
|
||||
onConfirm: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UsersHeaderProps {
|
||||
onCreateUser: () => void;
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
username: string;
|
||||
email: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
export interface UsersTableProps {
|
||||
users: ListUsers200Item[];
|
||||
currentUser: AuthUser | null;
|
||||
onEdit: (user: ListUsers200Item) => void;
|
||||
onDelete: (user: ListUsers200Item) => void;
|
||||
onToggleStatus: (user: ListUsers200Item) => void;
|
||||
}
|
@@ -77,7 +77,7 @@ export function Navbar() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/admin" className="flex items-center gap-2 cursor-pointer">
|
||||
<Link href="/users-management" className="flex items-center gap-2 cursor-pointer">
|
||||
<IconUsers className="h-4 w-4" />
|
||||
{t("navbar.usersManagement")}
|
||||
</Link>
|
||||
|
@@ -57,7 +57,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground cursor-pointer data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
16
apps/app/src/hooks/use-disclosure.ts
Normal file
16
apps/app/src/hooks/use-disclosure.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useDisclosure(initial = false) {
|
||||
const [isOpen, setIsOpen] = useState(initial);
|
||||
|
||||
const onOpen = () => setIsOpen(true);
|
||||
const onClose = () => setIsOpen(false);
|
||||
const onToggle = () => setIsOpen(!isOpen);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onToggle,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user