Qassure-frontend/src/pages/tenant/Users.tsx

655 lines
20 KiB
TypeScript

import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
PrimaryButton,
StatusBadge,
ActionDropdown,
NewUserModal,
ViewUserModal,
EditUserModal,
// DeleteConfirmationModal,
DataTable,
Pagination,
FilterDropdown,
SearchBox,
type Column,
} from "@/components/shared";
import { Plus, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service";
import { roleService } from "@/services/role-service";
import type { User } from "@/types/user";
import type { Role } from "@/types/role";
import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions";
import { useAppTheme } from "@/hooks/useAppTheme";
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
return `${firstName[0]}${lastName[0]}`.toUpperCase();
};
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Helper function to get status badge variant
const getStatusVariant = (
status: string,
): "success" | "failure" | "process" => {
switch (status.toLowerCase()) {
case "active":
return "success";
case "pending_verification":
return "process";
case "inactive":
return "failure";
case "deleted":
return "failure";
case "suspended":
return "process";
default:
return "success";
}
};
const Users = (): ReactElement => {
const { primaryColor } = useAppTheme();
const { canCreate, canUpdate
// , canDelete
} = usePermissions();
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [isCreating, setIsCreating] = useState<boolean>(false);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(5);
const [pagination, setPagination] = useState<{
page: number;
limit: number;
total: number;
totalPages: number;
hasMore: boolean;
}>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasMore: false,
});
// Filter state
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [roleFilter, setRoleFilter] = useState<string | null>(null);
// Search state
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
// Roles list for filter
const [roles, setRoles] = useState<Role[]>([]);
// View, Edit, Delete modals
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
// const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
// const [selectedUserName, setSelectedUserName] = useState<string>("");
const [isUpdating, setIsUpdating] = useState<boolean>(false);
// const [isDeleting, setIsDeleting] = useState<boolean>(false);
const fetchUsers = async (
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null,
searchQuery: string | null = null,
roleId: string | null = null,
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await userService.getAll(
page,
itemsPerPage,
status,
sortBy,
searchQuery,
roleId,
);
if (response.success) {
setUsers(response.data);
setPagination(response.pagination);
} else {
setError("Failed to load users");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || "Failed to load users");
} finally {
setIsLoading(false);
}
};
// Handle search debouncing
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(search);
// We only reset to first page if we are actively searching.
if (search) setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [search]);
// Fetch roles for filter
useEffect(() => {
const fetchRoles = async () => {
try {
const response = await roleService.getAll(1, 100);
if (response.success) {
setRoles(response.data);
}
} catch (err) {
console.error("Failed to fetch roles:", err);
}
};
fetchRoles();
}, []);
// Fetch users on mount and when pagination/filters change
useEffect(() => {
fetchUsers(currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter);
}, [currentPage, limit, statusFilter, orderBy, debouncedSearch, roleFilter]);
const handleCreateUser = async (data: {
email: string;
password: string;
first_name: string;
last_name: string;
status: "active" | "suspended" | "deleted";
auth_provider: "local";
role_module_combinations: { role_id: string; module_id?: string | null }[];
department_id?: string;
designation_id?: string;
}): Promise<void> => {
try {
setIsCreating(true);
const response = await userService.create(data);
const message = response.message || `User created successfully`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsCreating(false);
}
};
// View user handler
const handleViewUser = (userId: string): void => {
setSelectedUserId(userId);
setViewModalOpen(true);
};
// Edit user handler
const handleEditUser = (userId: string
// , userName: string
): void => {
setSelectedUserId(userId);
// setSelectedUserName(userName);
setEditModalOpen(true);
};
// Update user handler
const handleUpdateUser = async (
id: string,
data: {
email: string;
first_name: string;
last_name: string;
status: "active" | "suspended" | "deleted";
tenant_id: string;
role_module_combinations: { role_id: string; module_id?: string | null }[];
department_id?: string;
designation_id?: string;
},
): Promise<void> => {
try {
setIsUpdating(true);
const response = await userService.update(id, data);
const message = response.message || `User updated successfully`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedUserId(null);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
} finally {
setIsUpdating(false);
}
};
// Delete user handler
// const handleDeleteUser = (userId: string, userName: string): void => {
// setSelectedUserId(userId);
// setSelectedUserName(userName);
// setDeleteModalOpen(true);
// };
// Confirm delete handler
// const handleConfirmDelete = async (): Promise<void> => {
// if (!selectedUserId) return;
// try {
// setIsDeleting(true);
// await userService.delete(selectedUserId);
// setDeleteModalOpen(false);
// setSelectedUserId(null);
// setSelectedUserName("");
// await fetchUsers(currentPage, limit, statusFilter, orderBy);
// } catch (err: any) {
// throw err;
// } finally {
// setIsDeleting(false);
// }
// };
// Load user for view/edit
const loadUser = async (id: string): Promise<User> => {
const response = await userService.getById(id);
return response.data;
};
// Define table columns
const columns: Column<User>[] = [
{
key: "name",
label: "User Name",
render: (user) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<span className="text-sm font-normal text-[#0f1724]">
{user.first_name} {user.last_name}
</span>
</div>
),
mobileLabel: "Name",
},
{
key: "email",
label: "Email",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">{user.email}</span>
),
},
{
key: "role",
label: "Role",
render: (user) => (
<div className="flex flex-wrap gap-1">
{user.role_module_combinations && user.role_module_combinations.length > 0 ? (
user.role_module_combinations.map((combo, idx) => (
<span
key={`${combo.role_id}-${combo.module_id || 'global'}-${idx}`}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
title={combo.module_name ? `Role for ${combo.module_name}` : "Global Role"}
>
{combo.role_name} {combo.module_name && `(${combo.module_name})`}
</span>
))
) : user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{ backgroundColor: `${primaryColor}1A`, color: primaryColor }}
>
{role.name}
</span>
))
) : (
<span className="text-sm font-normal text-[#0f1724]">
{user.role?.name || "-"}
</span>
)}
</div>
),
},
{
key: "status",
label: "Status",
render: (user) => (
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
),
},
{
key: "auth_provider",
label: "Auth Provider",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">
{user.auth_provider}
</span>
),
},
{
key: "created_at",
label: "Joined Date",
render: (user) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(user.created_at)}
</span>
),
mobileLabel: "Joined",
},
{
key: "actions",
label: "Actions",
align: "right",
render: (user) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={
canUpdate("users")
? () =>
handleEditUser(
user.id,
// `${user.first_name} ${user.last_name}`,
)
: undefined
}
// onDelete={
// canDelete("users")
// ? () =>
// handleDeleteUser(
// user.id,
// `${user.first_name} ${user.last_name}`,
// )
// : undefined
// }
/>
</div>
),
},
];
// Mobile card renderer
const mobileCardRenderer = (user: User) => (
<div className="p-4">
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
<span className="text-xs font-normal text-[#9aa6b2]">
{getUserInitials(user.first_name, user.last_name)}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{user.first_name} {user.last_name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate">
{user.email}
</p>
</div>
</div>
<ActionDropdown
onView={() => handleViewUser(user.id)}
onEdit={
canUpdate("users")
? () =>
handleEditUser(
user.id,
// `${user.first_name} ${user.last_name}`,
)
: undefined
}
// onDelete={
// canDelete("users")
// ? () =>
// handleDeleteUser(
// user.id,
// `${user.first_name} ${user.last_name}`,
// )
// : undefined
// }
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Auth Provider:</span>
<p className="text-[#0f1724] font-normal mt-1">
{user.auth_provider}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Joined:</span>
<p className="text-[#6b7280] font-normal mt-1">
{formatDate(user.created_at)}
</p>
</div>
</div>
</div>
);
return (
<Layout
currentPage="Users"
pageHeader={{
title: "User List",
description:
"View and manage all users in your QAssure platform from a single place.",
}}
>
{/* Table Container */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Table Header with Filters */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
{/* Global Search */}
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or email..."
/>
{/* Status Filter */}
<FilterDropdown
label="Status"
options={[
{ value: "active", label: "Active" },
// {
// value: "pending_verification",
// label: "Pending Verification",
// },
// { value: "inactive", label: "Inactive" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
]}
value={statusFilter}
onChange={(value) => {
setStatusFilter(value as string | null);
setCurrentPage(1); // Reset to first page when filter changes
}}
placeholder="All"
/>
{/* Role Filter */}
<FilterDropdown
label="Role"
options={[
// { value: "", label: "All" },
...roles.map(role => ({ value: role.id, label: role.name }))
]}
value={roleFilter || ""}
onChange={(value) => {
setRoleFilter(Array.isArray(value) ? null : value || null);
setCurrentPage(1);
}}
placeholder="All"
/>
{/* Sort Filter */}
<FilterDropdown
label="Sort by"
options={[
{ value: ["first_name", "asc"], label: "First Name (A-Z)" },
{ value: ["first_name", "desc"], label: "First Name (Z-A)" },
{ value: ["last_name", "asc"], label: "Last Name (A-Z)" },
{ value: ["last_name", "desc"], label: "Last Name (Z-A)" },
{ value: ["email", "asc"], label: "Email (A-Z)" },
{ value: ["email", "desc"], label: "Email (Z-A)" },
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
{ value: ["created_at", "desc"], label: "Created (Newest)" },
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1); // Reset to first page when sort changes
}}
placeholder="Default"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Export Button */}
{/* <button
type="button"
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" />
<span>Export</span>
</button> */}
{/* New User Button */}
{canCreate("users") && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
)}
</div>
</div>
{/* Data Table */}
<DataTable
data={users}
columns={columns}
keyExtractor={(user) => user.id}
mobileCardRenderer={mobileCardRenderer}
emptyMessage="No users found"
isLoading={isLoading}
error={error}
/>
{/* Table Footer with Pagination */}
{pagination.total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={(page: number) => {
setCurrentPage(page);
}}
onLimitChange={(newLimit: number) => {
setLimit(newLimit);
setCurrentPage(1); // Reset to first page when limit changes
}}
/>
)}
</div>
{/* New User Modal */}
<NewUserModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleCreateUser}
isLoading={isCreating}
/>
{/* View User Modal */}
<ViewUserModal
isOpen={viewModalOpen}
onClose={() => {
setViewModalOpen(false);
setSelectedUserId(null);
}}
userId={selectedUserId}
onLoadUser={loadUser}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={editModalOpen}
onClose={() => {
setEditModalOpen(false);
setSelectedUserId(null);
// setSelectedUserName("");
}}
userId={selectedUserId}
onLoadUser={loadUser}
onSubmit={handleUpdateUser}
isLoading={isUpdating}
/>
{/* Delete Confirmation Modal */}
{/* <DeleteConfirmationModal
isOpen={deleteModalOpen}
onClose={() => {
setDeleteModalOpen(false);
setSelectedUserId(null);
setSelectedUserName("");
}}
onConfirm={handleConfirmDelete}
title="Delete User"
message="Are you sure you want to delete this user"
itemName={selectedUserName}
isLoading={isDeleting}
/> */}
</Layout>
);
};
export default Users;