From 7e8dab160b40fb44f741ba93d18816c6f2c2bd8f Mon Sep 17 00:00:00 2001 From: amee Date: Fri, 17 Apr 2026 10:35:19 +0530 Subject: [PATCH] filter functionality generic filter implementation --- .../authentication/Components/SignInForm.tsx | 39 --- src/application/logs/LogsPage.tsx | 267 +++++++++++++--- src/application/roles/RolesApi.ts | 20 +- src/application/roles/components/AllRoles.tsx | 141 +++++++-- .../subscriptions/SubscriptionsApi.ts | 22 +- .../components/AllSubscriptions.tsx | 169 ++++++++-- src/application/tenants/TenantsApi.ts | 25 +- .../tenants/components/AllTenants.tsx | 264 ++++++++++++---- src/application/users/UserApi.ts | 28 +- src/application/users/components/AllUsers.tsx | 280 +++++++++++----- src/components/custom/CustomColumnFilter.tsx | 299 ++++++++++++++++++ .../custom/CustomColumnFilter.utils.ts | 24 ++ src/components/custom/index.ts | 2 + src/i18n/config.ts | 4 + src/i18n/locales/ar/common.json | 1 + src/i18n/locales/ar/logs.json | 15 + src/i18n/locales/en/common.json | 3 +- src/i18n/locales/en/logs.json | 15 + src/lib/dateFormat.ts | 28 ++ src/lib/queryParams.ts | 21 ++ 20 files changed, 1341 insertions(+), 326 deletions(-) create mode 100644 src/components/custom/CustomColumnFilter.tsx create mode 100644 src/components/custom/CustomColumnFilter.utils.ts create mode 100644 src/i18n/locales/ar/logs.json create mode 100644 src/i18n/locales/en/logs.json create mode 100644 src/lib/dateFormat.ts create mode 100644 src/lib/queryParams.ts diff --git a/src/application/authentication/Components/SignInForm.tsx b/src/application/authentication/Components/SignInForm.tsx index d327b17..48e473f 100644 --- a/src/application/authentication/Components/SignInForm.tsx +++ b/src/application/authentication/Components/SignInForm.tsx @@ -55,45 +55,6 @@ export default function SignInForm() {

-
- -
-
-
-
-
-
- - Or - -
-
{ +interface AuditLog extends Record { id: string; module_name: string; action_type: string; @@ -24,57 +32,230 @@ interface AuditLogListResponse { } const LogsPage = () => { + const { t, i18n } = useTranslation(["logs", "common"]); const [logs, setLogs] = useState([]); + const [allLogsForCounts, setAllLogsForCounts] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(""); + const [moduleFilter, setModuleFilter] = useState([]); + const [actionFilter, setActionFilter] = useState([]); + const [emailFilter, setEmailFilter] = useState([]); + const [totalRows, setTotalRows] = useState(0); + const [activeSort, setActiveSort] = useState<{ + column: "module_name" | "action_type" | "performed_by_email" | "created_at" | null; + direction: ColumnSortDirection; + }>({ column: "created_at", direction: "desc" }); + + const debouncedSearch = useDebounce(search, 500); + const searchInputRef = useRef(null); + const prevLoadingRef = useRef(isLoading); + const latestLogsRequestRef = useRef(0); + + const fetchLogs = useCallback(async () => { + const requestId = ++latestLogsRequestRef.current; - const fetchLogs = async () => { try { setIsLoading(true); + const queryString = buildQueryString({ + limit: pageSize, + offset: (page - 1) * pageSize, + search: debouncedSearch || undefined, + module_names: moduleFilter, + action_types: actionFilter, + performed_by_emails: emailFilter, + sort_by: activeSort.column ?? undefined, + sort_order: activeSort.direction ?? undefined, + }); + const response = await apiClient.get( - '/api/admin/audit-logs/', + `/api/admin/audit-logs/${queryString}`, { silent: true } ); - setLogs(response.items); + + if (requestId === latestLogsRequestRef.current) { + setLogs(response.items); + setTotalRows(response.total); + } } catch (error) { - console.error("Failed to fetch logs", error); + console.error(t("messages.loadError"), error); } finally { - setIsLoading(false); + if (requestId === latestLogsRequestRef.current) { + setIsLoading(false); + } } - }; + }, [actionFilter, activeSort, debouncedSearch, emailFilter, page, pageSize, moduleFilter, t]); + + const fetchFilterOptions = useCallback(async () => { + try { + const response = await apiClient.get( + "/api/admin/audit-logs/?limit=500&offset=0", + { silent: true } + ); + setAllLogsForCounts(response.items); + } catch (error) { + console.error(t("messages.loadError"), error); + } + }, [t]); useEffect(() => { fetchLogs(); - }, []); + }, [fetchLogs]); - const columns: ColumnDef[] = [ - { key: "module_name", header: "Module", searchable: true }, - { - key: "action_type", - header: "Action", - render: (row) => ( - - {row.action_type} - - ) - }, - { key: "description", header: "Description" }, - { key: "performed_by_email", header: "Performed By", searchable: true }, - { key: "ip_address", header: "IP Address" }, - { - key: "created_at", - header: "Timestamp", - render: (row) => new Date(row.created_at).toLocaleString() - }, - ]; + useEffect(() => { + fetchFilterOptions(); + }, [fetchFilterOptions]); + + useEffect(() => { + setPage(1); + }, [debouncedSearch, moduleFilter, actionFilter, emailFilter, activeSort]); + + useEffect(() => { + if (prevLoadingRef.current && !isLoading && search.trim()) { + searchInputRef.current?.focus({ preventScroll: true }); + } + prevLoadingRef.current = isLoading; + }, [isLoading, search]); + + const moduleCounts = useMemo(() => { + const counts = new Map(); + allLogsForCounts.forEach((log) => { + counts.set(log.module_name, (counts.get(log.module_name) ?? 0) + 1); + }); + return counts; + }, [allLogsForCounts]); + + const actionCounts = useMemo(() => { + const counts = new Map(); + allLogsForCounts.forEach((log) => { + counts.set(log.action_type, (counts.get(log.action_type) ?? 0) + 1); + }); + return counts; + }, [allLogsForCounts]); + + const emailCounts = useMemo(() => { + const counts = new Map(); + allLogsForCounts.forEach((log) => { + counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1); + }); + return counts; + }, [allLogsForCounts]); + + const columns: ColumnDef[] = useMemo( + () => [ + { + key: "module_name", + header: ( +
+ {t("columns.module")} + { + setPage(1); + setModuleFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "module_name", direction)); + }} + /> +
+ ), + searchable: false, + }, + { + key: "action_type", + header: ( +
+ {t("columns.action")} + { + setPage(1); + setActionFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "action_type", direction)); + }} + /> +
+ ), + searchable: false, + render: (row) => ( + + {row.action_type} + + ), + }, + { key: "description", header: t("columns.description") }, + { + key: "performed_by_email", + header: ( +
+ {t("columns.performedBy")} + { + setPage(1); + setEmailFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction)); + }} + /> +
+ ), + searchable: false, + }, + { key: "ip_address", header: t("columns.ipAddress") }, + { + key: "created_at", + header: ( +
+ {t("columns.timestamp")} + { + setPage(1); + setActiveSort(resolveColumnSortState(activeSort, "created_at", direction)); + }} + /> +
+ ), + searchable: false, + render: (row) => + new Date(row.created_at).toLocaleString( + i18n.language === "ar" ? "ar-EG" : "en-GB" + ), + }, + ], + [actionCounts, actionFilter, activeSort, emailCounts, emailFilter, i18n.language, moduleCounts, moduleFilter, t] + ); return (

- System Logs + {t("title")}

{ loading={isLoading} leftIcon={} > - Refresh + {t("common:actions.refresh")}
@@ -90,10 +271,20 @@ const LogsPage = () => { data={logs} columns={columns} isLoading={isLoading} - exportFileName="audit_logs.csv" + exportFileName={t("exportFileName")} + manualPagination + manualFiltering + totalRows={totalRows} + page={page} + pageSize={pageSize} + search={search} + onPageChange={setPage} + onPageSizeChange={setPageSize} + onSearchChange={setSearch} + searchInputRef={searchInputRef} />
); }; -export default LogsPage; \ No newline at end of file +export default LogsPage; diff --git a/src/application/roles/RolesApi.ts b/src/application/roles/RolesApi.ts index f3a3fb9..ec32b22 100644 --- a/src/application/roles/RolesApi.ts +++ b/src/application/roles/RolesApi.ts @@ -1,4 +1,5 @@ import { apiClient } from "../../lib/apiClient"; +import { buildQueryString } from "../../lib/queryParams"; import type { Role, RoleCreateRequest, @@ -9,17 +10,6 @@ import type { type RoleWithAccesses = Role & { accesses: RoleAccess[] }; -const buildQueryString = (params: Record): string => { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.append(key, String(value)); - } - }); - const query = searchParams.toString(); - return query ? `?${query}` : ""; -}; - export const rolesApi = { getAll: () => apiClient.get("/api/role/get"), @@ -41,11 +31,19 @@ export const rolesApi = { page?: number; page_size?: number; search?: string; + filter_role_names?: string[]; + filter_tenant_ids?: string[]; + sort_by?: "name" | "tenant"; + sort_order?: "asc" | "desc"; }) => { const queryString = buildQueryString({ page: params.page, page_size: params.page_size, search: params.search, + filter_role_names: params.filter_role_names ?? undefined, + filter_tenant_ids: params.filter_tenant_ids ?? undefined, + sort_by: params.sort_by ?? undefined, + sort_order: params.sort_order ?? undefined, }); return apiClient.get(`/api/role/list${queryString}`); }, diff --git a/src/application/roles/components/AllRoles.tsx b/src/application/roles/components/AllRoles.tsx index e718b6d..eb72844 100644 --- a/src/application/roles/components/AllRoles.tsx +++ b/src/application/roles/components/AllRoles.tsx @@ -4,6 +4,7 @@ import { Edit2, Eye, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { CustomButton, + CustomColumnFilter, CustomConfirmationModal, CustomInput, CustomModal, @@ -15,6 +16,12 @@ import { import { GroupedAccessSelector } from "./GroupedAccessSelector"; import { GroupedAccessViewer } from "./GroupedAccessViewer"; import type { ColumnDef } from "../../../components/custom/CustomTable"; +import { + buildColumnFilterOptions, + resolveColumnSortState, +} from "../../../components/custom/CustomColumnFilter.utils"; +import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter"; +import { formatDate } from "../../../lib/dateFormat"; import type { Role, RoleAccess, @@ -28,31 +35,6 @@ import { ProtectedComponent } from "../../../components/auth/ProtectedComponent" import { useAuth } from "../../../context/AuthContext"; import { useDebounce } from "../../../components/hooks/useDebounce"; -const formatDate = (dateString?: string | null, language: string = 'en') => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return dateString; - - const hasTime = dateString.includes("T") || dateString.includes(":"); - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - }; - - if (hasTime) { - options.hour = "2-digit"; - options.minute = "2-digit"; - options.hour12 = false; - } - - return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options); - } catch { - return dateString; - } -}; - const AllRoles = () => { const { t, i18n } = useTranslation(['roles', 'common']); const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth(); @@ -66,12 +48,19 @@ const AllRoles = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); + const [roleNameFilter, setRoleNameFilter] = useState([]); + const [tenantFilter, setTenantFilter] = useState([]); const [totalRows, setTotalRows] = useState(0); const [, setTotalPages] = useState(0); + const [activeSort, setActiveSort] = useState<{ + column: "role_name" | "tenant_id" | null; + direction: ColumnSortDirection; + }>({ column: null, direction: null }); const debouncedSearch = useDebounce(search, 500); const searchInputRef = useRef(null); const prevLoadingRef = useRef(isLoading); + const latestRolesRequestRef = useRef(0); const [selectedRole, setSelectedRole] = useState(null); const [selectedRoleAccesses, setSelectedRoleAccesses] = useState(null); @@ -83,6 +72,7 @@ const AllRoles = () => { const [accessOptions, setAccessOptions] = useState([]); const [isAccessLoading, setIsAccessLoading] = useState(false); const [tenants, setTenants] = useState([]); + const [allRolesForCounts, setAllRolesForCounts] = useState([]); const [currentTenant, setCurrentTenant] = useState(null); const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" }); @@ -128,6 +118,9 @@ const AllRoles = () => { const accesses = await rolesApi.getAccesses(); if (isMounted) setAccessOptions(accesses); + const allRoles = await rolesApi.getAll(); + if (isMounted) setAllRolesForCounts(allRoles); + if (canReadAllTenants) { const tenantsData = await tenantsApi.getAll(); if (isMounted) setTenants(tenantsData); @@ -143,6 +136,7 @@ const AllRoles = () => { }; const loadRoles = async () => { + const requestId = ++latestRolesRequestRef.current; setIsLoading(true); setErrorMessage(""); @@ -151,20 +145,29 @@ const AllRoles = () => { page, page_size: pageSize, search: debouncedSearch || undefined, + filter_role_names: roleNameFilter, + filter_tenant_ids: tenantFilter, + sort_by: + activeSort.column === "role_name" + ? "name" + : activeSort.column === "tenant_id" + ? "tenant" + : undefined, + sort_order: activeSort.direction ?? undefined, }); - if (isMounted) { + if (isMounted && requestId === latestRolesRequestRef.current) { setRoles(response.items); setTotalRows(response.total); setTotalPages(response.total_pages); } } catch (err) { - if (isMounted) { + if (isMounted && requestId === latestRolesRequestRef.current) { const msg = err instanceof Error ? err.message : t('messages.error'); setErrorMessage(msg); } } finally { - if (isMounted) setIsLoading(false); + if (isMounted && requestId === latestRolesRequestRef.current) setIsLoading(false); } }; @@ -183,12 +186,15 @@ const AllRoles = () => { page, pageSize, debouncedSearch, + roleNameFilter, + tenantFilter, + activeSort, t ]); useEffect(() => { setPage(1); - }, [debouncedSearch]); + }, [debouncedSearch, roleNameFilter, tenantFilter, activeSort]); useEffect(() => { if (prevLoadingRef.current && !isLoading && search.trim()) { @@ -211,6 +217,23 @@ const AllRoles = () => { [tenants, currentTenant, currentUser] ); + const roleNameCounts = useMemo(() => { + const counts = new Map(); + allRolesForCounts.forEach((role) => { + counts.set(role.role_name, (counts.get(role.role_name) ?? 0) + 1); + }); + return counts; + }, [allRolesForCounts]); + + const tenantCounts = useMemo(() => { + const counts = new Map(); + allRolesForCounts.forEach((role) => { + if (!role.tenant_id) return; + counts.set(role.tenant_id, (counts.get(role.tenant_id) ?? 0) + 1); + }); + return counts; + }, [allRolesForCounts]); + const openView = useCallback(async (role: Role) => { setSelectedRole(role); setSelectedRoleAccesses(null); @@ -315,10 +338,48 @@ const AllRoles = () => { const columns = useMemo>>( () => [ - { key: "role_name", header: t('columns.roleName') }, + { + key: "role_name", + header: ( +
+ {t('columns.roleName')} + { + setPage(1); + setRoleNameFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "role_name", direction)); + }} + /> +
+ ), + }, { key: "tenant_id", - header: t('columns.tenant'), + header: ( +
+ {t('columns.tenant')} + {canReadAllTenants ? ( + ({ + label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`, + value: tenant.id, + }))} + selectedValues={tenantFilter} + sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null} + onApply={(values, direction) => { + setPage(1); + setTenantFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction)); + }} + /> + ) : null} +
+ ), render: (row) => getTenantName(row.tenant_id), }, { @@ -370,7 +431,21 @@ const AllRoles = () => { ), }, ], - [getTenantName, openView, openEdit, openDelete, t, i18n.language] + [ + activeSort, + canReadAllTenants, + getTenantName, + i18n.language, + openDelete, + openEdit, + openView, + roleNameCounts, + roleNameFilter, + t, + tenantCounts, + tenantFilter, + tenants, + ] ); return ( @@ -525,4 +600,4 @@ const AllRoles = () => { ); }; -export default AllRoles; \ No newline at end of file +export default AllRoles; diff --git a/src/application/subscriptions/SubscriptionsApi.ts b/src/application/subscriptions/SubscriptionsApi.ts index 1f2f667..2227977 100644 --- a/src/application/subscriptions/SubscriptionsApi.ts +++ b/src/application/subscriptions/SubscriptionsApi.ts @@ -1,4 +1,5 @@ import { apiClient } from "../../lib/apiClient"; +import { buildQueryString } from "../../lib/queryParams"; import type { SubscriptionPlan, SubscriptionPlanDetail, @@ -8,17 +9,6 @@ import type { } from "./SubscriptionTypes"; import type { RoleAccess } from "../roles/RolesTypes"; -const buildQueryString = (params: Record): string => { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.append(key, String(value)); - } - }); - const query = searchParams.toString(); - return query ? `?${query}` : ""; -}; - export const subscriptionsApi = { getAll: (params?: { is_public?: boolean; status?: string }) => { const queryString = params ? buildQueryString(params) : ""; @@ -61,11 +51,21 @@ export const subscriptionsApi = { page?: number; page_size?: number; search?: string; + filter_names?: string[]; + statuses?: string[]; + visibility?: boolean[]; + sort_by?: "name" | "price" | "status" | "visibility"; + sort_order?: "asc" | "desc"; }) => { const queryString = buildQueryString({ page: params.page, page_size: params.page_size, search: params.search, + filter_names: params.filter_names ?? undefined, + statuses: params.statuses ?? undefined, + visibility: params.visibility ?? undefined, + sort_by: params.sort_by ?? undefined, + sort_order: params.sort_order ?? undefined, }); return apiClient.get( `/api/subscription-plan/list${queryString}` diff --git a/src/application/subscriptions/components/AllSubscriptions.tsx b/src/application/subscriptions/components/AllSubscriptions.tsx index 32fac7d..e404b3f 100644 --- a/src/application/subscriptions/components/AllSubscriptions.tsx +++ b/src/application/subscriptions/components/AllSubscriptions.tsx @@ -4,6 +4,7 @@ import { Edit2, Eye, Trash2 } from "lucide-react"; import { CustomButton, CustomCheckBox, + CustomColumnFilter, CustomConfirmationModal, CustomDropdown, CustomInput, @@ -17,6 +18,12 @@ import { import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector"; import { GroupedAccessViewer } from "../../roles/components/GroupedAccessViewer"; import type { ColumnDef } from "../../../components/custom/CustomTable"; +import { + buildColumnFilterOptions, + resolveColumnSortState, +} from "../../../components/custom/CustomColumnFilter.utils"; +import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter"; +import { formatDate } from "../../../lib/dateFormat"; import type { SubscriptionPlan, SubscriptionPlanUpdateRequest } from "../SubscriptionTypes"; import type { RoleAccess } from "../../roles/RolesTypes"; import { subscriptionsApi } from "../SubscriptionsApi"; @@ -24,31 +31,6 @@ import { ProtectedComponent } from "../../../components/auth/ProtectedComponent" import { useAuth } from "../../../context/AuthContext"; import { useDebounce } from "../../../components/hooks/useDebounce"; -const formatDate = (dateString?: string | null) => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return dateString; - - const hasTime = dateString.includes("T") || dateString.includes(":"); - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - }; - - if (hasTime) { - options.hour = "2-digit"; - options.minute = "2-digit"; - options.hour12 = false; - } - - return date.toLocaleString("en-GB", options); - } catch { - return dateString; - } -}; - const formatPrice = (price?: number | null) => { if (price == null) return "-"; return new Intl.NumberFormat("en-US", { @@ -75,12 +57,21 @@ const AllSubscriptions = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); + const [nameFilter, setNameFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [visibilityFilter, setVisibilityFilter] = useState([]); const [totalRows, setTotalRows] = useState(0); const [, setTotalPages] = useState(0); + const [activeSort, setActiveSort] = useState<{ + column: "name" | "status" | "is_public" | null; + direction: ColumnSortDirection; + }>({ column: null, direction: null }); + const [allPlansForCounts, setAllPlansForCounts] = useState([]); const debouncedSearch = useDebounce(search, 500); const searchInputRef = useRef(null); const prevLoadingRef = useRef(isLoading); + const latestPlansRequestRef = useRef(0); const [selectedPlan, setSelectedPlan] = useState(null); const [isViewOpen, setIsViewOpen] = useState(false); @@ -113,6 +104,9 @@ const AllSubscriptions = () => { try { const data = await subscriptionsApi.getAccesses(); if (isMounted) setAllAccesses(data); + + const allPlans = await subscriptionsApi.getAll(); + if (isMounted) setAllPlansForCounts(allPlans); } catch (err) { console.error("Failed to load accesses:", err); } finally { @@ -129,6 +123,7 @@ const AllSubscriptions = () => { const loadPlans = async () => { if (isAuthLoading) return; + const requestId = ++latestPlansRequestRef.current; setIsLoading(true); setErrorMessage(""); @@ -137,31 +132,43 @@ const AllSubscriptions = () => { page, page_size: pageSize, search: debouncedSearch || undefined, + filter_names: nameFilter, + statuses: statusFilter, + visibility: visibilityFilter, + sort_by: + activeSort.column === "name" + ? "name" + : activeSort.column === "status" + ? "status" + : activeSort.column === "is_public" + ? "visibility" + : undefined, + sort_order: activeSort.direction ?? undefined, }); - if (isMounted) { + if (isMounted && requestId === latestPlansRequestRef.current) { setPlans(response.items); setTotalRows(response.total); setTotalPages(response.total_pages); } } catch (error) { - if (isMounted) { + if (isMounted && requestId === latestPlansRequestRef.current) { const message = error instanceof Error ? error.message : "Unable to load plans."; setErrorMessage(message); } } finally { - if (isMounted) setIsLoading(false); + if (isMounted && requestId === latestPlansRequestRef.current) setIsLoading(false); } }; loadPlans(); return () => { isMounted = false; }; - }, [isAuthLoading, page, pageSize, debouncedSearch]); + }, [activeSort, debouncedSearch, isAuthLoading, nameFilter, page, pageSize, statusFilter, visibilityFilter]); useEffect(() => { setPage(1); - }, [debouncedSearch]); + }, [debouncedSearch, nameFilter, statusFilter, visibilityFilter, activeSort]); useEffect(() => { if (prevLoadingRef.current && !isLoading && search.trim()) { @@ -176,6 +183,31 @@ const AllSubscriptions = () => { return allAccesses.filter((a) => idSet.has(a.id)); }, [viewAccessIds, allAccesses]); + const nameCounts = useMemo(() => { + const counts = new Map(); + allPlansForCounts.forEach((plan) => { + counts.set(plan.name, (counts.get(plan.name) ?? 0) + 1); + }); + return counts; + }, [allPlansForCounts]); + + const planStatusCounts = useMemo(() => { + const counts = new Map(); + allPlansForCounts.forEach((plan) => { + counts.set(plan.status, (counts.get(plan.status) ?? 0) + 1); + }); + return counts; + }, [allPlansForCounts]); + + const visibilityCounts = useMemo(() => { + const counts = new Map(); + allPlansForCounts.forEach((plan) => { + const key = plan.is_public ? "true" : "false"; + counts.set(key, (counts.get(key) ?? 0) + 1); + }); + return counts; + }, [allPlansForCounts]); + const splitAccessIds = useCallback( (ids: string[]) => { const accessIds: string[] = []; @@ -320,7 +352,25 @@ const AllSubscriptions = () => { const columns = useMemo>>( () => [ - { key: "name", header: "Plan Name" }, + { + key: "name", + header: ( +
+ Plan Name + { + setPage(1); + setNameFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "name", direction)); + }} + /> +
+ ), + }, { key: "price", header: "Price", @@ -333,7 +383,26 @@ const AllSubscriptions = () => { }, { key: "status", - header: "Status", + header: ( +
+ Status + { + setPage(1); + setStatusFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "status", direction)); + }} + /> +
+ ), searchable: false, render: (row) => ( { }, { key: "is_public", - header: "Visibility", + header: ( +
+ Visibility + String(value))} + sortDirection={activeSort.column === "is_public" ? activeSort.direction : null} + enableSearch={false} + onApply={(values, direction) => { + setPage(1); + setVisibilityFilter(values.map((value) => value === "true")); + setActiveSort(resolveColumnSortState(activeSort, "is_public", direction)); + }} + /> +
+ ), searchable: false, render: (row) => ( { ), }, ], - [openView, openEdit, openDelete] + [ + activeSort, + nameCounts, + nameFilter, + openDelete, + openEdit, + openView, + planStatusCounts, + statusFilter, + visibilityCounts, + visibilityFilter, + ] ); return ( @@ -663,4 +762,4 @@ const AllSubscriptions = () => { ); }; -export default AllSubscriptions; \ No newline at end of file +export default AllSubscriptions; diff --git a/src/application/tenants/TenantsApi.ts b/src/application/tenants/TenantsApi.ts index ae61a39..95bfa4a 100644 --- a/src/application/tenants/TenantsApi.ts +++ b/src/application/tenants/TenantsApi.ts @@ -1,5 +1,6 @@ // src/features/Tenants/TenantsApi.ts import { apiClient } from "../../lib/apiClient"; +import { buildQueryString } from "../../lib/queryParams"; import type { ApiMessage, Tenant, @@ -8,18 +9,6 @@ import type { TenantUpdateRequest, } from "./TenantsTypes"; -// Helper to build URL with query params -const buildQueryString = (params: Record): string => { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - const query = searchParams.toString(); - return query ? `?${query}` : ""; -}; - export const tenantsApi = { getAll: () => apiClient.get("/api/tenant/get"), @@ -43,12 +32,24 @@ export const tenantsApi = { page_size?: number; search?: string; is_active?: boolean | null; + filter_tenant_names?: string[]; + filter_tenant_domains?: string[]; + filter_plan_ids?: string[]; + statuses?: boolean[]; + sort_by?: "name" | "domain" | "status" | "plan"; + sort_order?: "asc" | "desc"; }) => { const queryString = buildQueryString({ page: params.page, page_size: params.page_size, search: params.search, is_active: params.is_active, + filter_tenant_names: params.filter_tenant_names ?? undefined, + filter_tenant_domains: params.filter_tenant_domains ?? undefined, + filter_plan_ids: params.filter_plan_ids ?? undefined, + statuses: params.statuses ?? undefined, + sort_by: params.sort_by ?? undefined, + sort_order: params.sort_order ?? undefined, }); return apiClient.get( `/api/tenant/list${queryString}` diff --git a/src/application/tenants/components/AllTenants.tsx b/src/application/tenants/components/AllTenants.tsx index 4ab8808..ef8d7cd 100644 --- a/src/application/tenants/components/AllTenants.tsx +++ b/src/application/tenants/components/AllTenants.tsx @@ -4,6 +4,7 @@ import { Edit2, Eye, Trash2 } from "lucide-react"; import { CustomButton, CustomCheckBox, + CustomColumnFilter, CustomConfirmationModal, CustomDropdown, CustomInput, @@ -15,6 +16,12 @@ import { CustomActionItem, } from "../../../components/custom"; import type { ColumnDef } from "../../../components/custom/CustomTable"; +import { + buildColumnFilterOptions, + resolveColumnSortState, +} from "../../../components/custom/CustomColumnFilter.utils"; +import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter"; +import { formatDate } from "../../../lib/dateFormat"; import type { Tenant, TenantUpdateRequest } from "../TenantsTypes"; import { tenantsApi } from "../TenantsApi"; import { useAuth } from "../../../context/AuthContext"; @@ -45,32 +52,6 @@ const ProtectedComponent: React.FC<{ return <>{children}; }; -const formatDate = (dateString?: string | null) => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return dateString; - - const hasTime = dateString.includes("T") || dateString.includes(":"); - - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - }; - - if (hasTime) { - options.hour = "2-digit", - options.minute = "2-digit", - options.hour12 = false; - } - - return date.toLocaleString("en-GB", options); - } catch { - return dateString; - } -}; - interface EditFormState { tenant_name: string; tenant_domain: string; @@ -110,14 +91,22 @@ const AllTenants = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); + const [tenantNameFilter, setTenantNameFilter] = useState([]); + const [tenantDomainFilter, setTenantDomainFilter] = useState([]); + const [planFilter, setPlanFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); const [totalRows, setTotalRows] = useState(0); const [, setTotalPages] = useState(0); - - const [statusFilter, setStatusFilter] = useState(null); + const [activeSort, setActiveSort] = useState<{ + column: "tenant_name" | "tenant_domain" | "plan_id" | "is_active" | null; + direction: ColumnSortDirection; + }>({ column: null, direction: null }); + const [allTenantsForCounts, setAllTenantsForCounts] = useState([]); const searchInputRef = useRef(null); const prevLoadingRef = useRef(isLoading); + const latestTenantsRequestRef = useRef(0); const debouncedSearch = useDebounce(search, 500); @@ -134,14 +123,29 @@ const AllTenants = () => { try { if (canReadAll) { + const requestId = ++latestTenantsRequestRef.current; const response = await tenantsApi.getPaginated({ page, page_size: pageSize, search: debouncedSearch || undefined, - is_active: statusFilter, + filter_tenant_names: tenantNameFilter, + filter_tenant_domains: tenantDomainFilter, + filter_plan_ids: planFilter, + statuses: statusFilter, + sort_by: + activeSort.column === "tenant_name" + ? "name" + : activeSort.column === "tenant_domain" + ? "domain" + : activeSort.column === "plan_id" + ? "plan" + : activeSort.column === "is_active" + ? "status" + : undefined, + sort_order: activeSort.direction ?? undefined, }); - if (isMounted) { + if (isMounted && requestId === latestTenantsRequestRef.current) { setTenants(response.items); setTotalRows(response.total); setTotalPages(response.total_pages); @@ -174,7 +178,18 @@ const AllTenants = () => { return () => { isMounted = false; }; - }, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]); + }, [ + activeSort, + canReadAll, + debouncedSearch, + isAuthLoading, + page, + pageSize, + planFilter, + statusFilter, + tenantDomainFilter, + tenantNameFilter, + ]); useEffect(() => { if (prevLoadingRef.current && !isLoading && search.trim() !== "") { @@ -185,16 +200,33 @@ const AllTenants = () => { useEffect(() => { setPage(1); - }, [debouncedSearch, statusFilter]); + }, [debouncedSearch, tenantNameFilter, tenantDomainFilter, planFilter, statusFilter, activeSort]); useEffect(() => { - if (!hasAccess("superadmin.tenant.update")) return; + if (!canReadAll) return; + + let isMounted = true; + const loadAllTenants = async () => { + try { + const data = await tenantsApi.getAll(); + if (isMounted) setAllTenantsForCounts(data); + } catch (error) { + console.error("Failed to load tenants for filters:", error); + } + }; + + loadAllTenants(); + return () => { isMounted = false; }; + }, [canReadAll]); + + useEffect(() => { + if (!canReadAll && !hasAccess("superadmin.tenant.update")) return; let isMounted = true; const loadPlans = async () => { setIsPlansLoading(true); try { - const data = await subscriptionsApi.getAll({ status: "active" }); + const data = await subscriptionsApi.getAll(); if (isMounted) setPlans(data); } catch (error) { console.error("Failed to load subscription plans:", error); @@ -205,7 +237,7 @@ const AllTenants = () => { loadPlans(); return () => { isMounted = false; }; - }, [hasAccess]); + }, [canReadAll, hasAccess]); const getPlanName = useCallback( (planId?: string | null) => { @@ -216,6 +248,40 @@ const AllTenants = () => { [plans] ); + const tenantNameCounts = useMemo(() => { + const counts = new Map(); + allTenantsForCounts.forEach((tenant) => { + counts.set(tenant.tenant_name, (counts.get(tenant.tenant_name) ?? 0) + 1); + }); + return counts; + }, [allTenantsForCounts]); + + const tenantDomainCounts = useMemo(() => { + const counts = new Map(); + allTenantsForCounts.forEach((tenant) => { + counts.set(tenant.tenant_domain, (counts.get(tenant.tenant_domain) ?? 0) + 1); + }); + return counts; + }, [allTenantsForCounts]); + + const planCounts = useMemo(() => { + const counts = new Map(); + allTenantsForCounts.forEach((tenant) => { + if (!tenant.plan_id) return; + counts.set(tenant.plan_id, (counts.get(tenant.plan_id) ?? 0) + 1); + }); + return counts; + }, [allTenantsForCounts]); + + const tenantStatusCounts = useMemo(() => { + const counts = new Map(); + allTenantsForCounts.forEach((tenant) => { + const key = tenant.is_active ? "true" : "false"; + counts.set(key, (counts.get(key) ?? 0) + 1); + }); + return counts; + }, [allTenantsForCounts]); + const openView = useCallback((tenant: Tenant) => { setSelectedTenant(tenant); setIsViewOpen(true); @@ -341,8 +407,48 @@ const AllTenants = () => { const columns = useMemo>>( () => [ - { key: "tenant_name", header: "Tenant Name" }, - { key: "tenant_domain", header: "Domain" }, + { + key: "tenant_name", + header: ( +
+ Tenant Name + {canReadAll ? ( + { + setPage(1); + setTenantNameFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "tenant_name", direction)); + }} + /> + ) : null} +
+ ), + }, + { + key: "tenant_domain", + header: ( +
+ Domain + {canReadAll ? ( + { + setPage(1); + setTenantDomainFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "tenant_domain", direction)); + }} + /> + ) : null} +
+ ), + }, { key: "tenant_logo_url", header: "Logo", @@ -360,7 +466,27 @@ const AllTenants = () => { }, { key: "plan_id", - header: "Plan", + header: ( +
+ Plan + {canReadAll && plans.length > 0 ? ( + ({ + label: `${plan.name} (${planCounts.get(plan.id) ?? 0})`, + value: plan.id, + }))} + selectedValues={planFilter} + sortDirection={activeSort.column === "plan_id" ? activeSort.direction : null} + onApply={(values, direction) => { + setPage(1); + setPlanFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "plan_id", direction)); + }} + /> + ) : null} +
+ ), searchable: false, render: (row) => ( @@ -370,7 +496,28 @@ const AllTenants = () => { }, { key: "is_active", - header: "Status", + header: ( +
+ Status + {canReadAll ? ( + String(value))} + sortDirection={activeSort.column === "is_active" ? activeSort.direction : null} + enableSearch={false} + onApply={(values, direction) => { + setPage(1); + setStatusFilter(values.map((value) => value === "true")); + setActiveSort(resolveColumnSortState(activeSort, "is_active", direction)); + }} + /> + ) : null} +
+ ), searchable: false, render: (row) => ( @@ -421,27 +568,25 @@ const AllTenants = () => { ), }, ], - [openDelete, openEdit, openView, getPlanName] + [ + activeSort, + canReadAll, + getPlanName, + openDelete, + openEdit, + openView, + planCounts, + planFilter, + plans, + statusFilter, + tenantDomainCounts, + tenantDomainFilter, + tenantNameCounts, + tenantNameFilter, + tenantStatusCounts, + ] ); - // Status filter control - const filterControls = canReadAll ? ( - - ) : null; - return (
@@ -478,7 +623,6 @@ const AllTenants = () => { onPageSizeChange={setPageSize} onSearchChange={setSearch} searchInputRef={searchInputRef} - filterControls={filterControls} /> )} @@ -672,4 +816,4 @@ const AllTenants = () => { ); }; -export default AllTenants; \ No newline at end of file +export default AllTenants; diff --git a/src/application/users/UserApi.ts b/src/application/users/UserApi.ts index a3f1a81..eb4315f 100644 --- a/src/application/users/UserApi.ts +++ b/src/application/users/UserApi.ts @@ -1,4 +1,5 @@ import { apiClient } from "../../lib/apiClient"; +import { buildQueryString } from "../../lib/queryParams"; import type { ApiMessage, User, @@ -9,17 +10,6 @@ import type { UserPaginatedResponse, } from "./UserTypes"; -const buildQueryString = (params: Record): string => { - const searchParams = new URLSearchParams(); - Object.entries(params).forEach(([key, value]) => { - if (value !== undefined && value !== null && value !== "") { - searchParams.append(key, String(value)); - } - }); - const query = searchParams.toString(); - return query ? `?${query}` : ""; -}; - const normalizeStatus = (status?: string): UserStatus => status?.toLowerCase() === "inactive" ? "inactive" : "active"; @@ -111,13 +101,25 @@ export const usersApi = { page?: number; page_size?: number; search?: string; - status?: UserStatus | null; + filter_names?: string[]; + filter_emails?: string[]; + statuses?: UserStatus[]; + filter_tenant_ids?: string[]; + filter_role_ids?: string[]; + sort_by?: "name" | "email" | "status" | "tenant" | "role"; + sort_order?: "asc" | "desc"; }) => { const queryString = buildQueryString({ page: params.page, page_size: params.page_size, search: params.search, - status: params.status ?? undefined, + filter_names: params.filter_names ?? undefined, + filter_emails: params.filter_emails ?? undefined, + statuses: params.statuses ?? undefined, + filter_tenant_ids: params.filter_tenant_ids ?? undefined, + filter_role_ids: params.filter_role_ids ?? undefined, + sort_by: params.sort_by ?? undefined, + sort_order: params.sort_order ?? undefined, }); return apiClient.get(`/api/user/list${queryString}`); }, diff --git a/src/application/users/components/AllUsers.tsx b/src/application/users/components/AllUsers.tsx index a14a2b9..1f462bc 100644 --- a/src/application/users/components/AllUsers.tsx +++ b/src/application/users/components/AllUsers.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { CustomButton, CustomCheckBox, + CustomColumnFilter, CustomConfirmationModal, CustomDropdown, CustomInput, @@ -17,12 +18,13 @@ import { CustomPhoneInput, } from "../../../components/custom"; import type { ColumnDef } from "../../../components/custom/CustomTable"; -import type { - User, - UserStatus, - UserUpdateRequest, - UserPaginatedResponse, -} from "../UserTypes"; +import { + buildColumnFilterOptions, + resolveColumnSortState, +} from "../../../components/custom/CustomColumnFilter.utils"; +import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter"; +import { formatDate } from "../../../lib/dateFormat"; +import type { User, UserStatus, UserUpdateRequest, UserPaginatedResponse } from "../UserTypes"; import { usersApi } from "../UserApi"; import { rolesApi } from "../../roles/RolesApi"; import type { Role } from "../../roles/RolesTypes"; @@ -32,39 +34,9 @@ import type { Tenant } from "../../tenants/TenantsTypes"; import { useDebounce } from "../../../components/hooks/useDebounce"; import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; - - const formatName = (firstName: string, lastName?: string | null) => [firstName, lastName].filter(Boolean).join(" "); -const formatDate = (dateString?: string | null, language: string = 'en') => { - if (!dateString) return ""; - try { - const date = new Date(dateString); - if (isNaN(date.getTime())) return dateString; - - const hasTime = dateString.includes("T") || dateString.includes(":"); - - const options: Intl.DateTimeFormatOptions = { - day: "numeric", - month: "short", - year: "numeric", - }; - - if (hasTime) { - options.hour = "2-digit"; - options.minute = "2-digit"; - options.hour12 = false; - } - - return date.toLocaleString(language === 'ar' ? 'ar-EG' : 'en-GB', options); - } catch { - return dateString; - } -}; - - - type EditFormState = { first_name: string; last_name: string; @@ -101,13 +73,21 @@ const AllUsers = () => { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); - const [statusFilter, setStatusFilter] = useState(null); + const [nameFilter, setNameFilter] = useState([]); + const [emailFilter, setEmailFilter] = useState([]); + const [statusFilter, setStatusFilter] = useState([]); + const [tenantFilter, setTenantFilter] = useState([]); + const [roleFilter, setRoleFilter] = useState([]); const [totalRows, setTotalRows] = useState(0); - const [, setTotalPages] = useState(0); + const [activeSort, setActiveSort] = useState<{ + column: "first_name" | "email" | "status" | "tenant_id" | "role_id" | null; + direction: ColumnSortDirection; + }>({ column: null, direction: null }); const debouncedSearch = useDebounce(search, 500); const searchInputRef = useRef(null); const prevLoadingRef = useRef(isLoading); + const latestUsersRequestRef = useRef(0); // Modals const [selectedUser, setSelectedUser] = useState(null); @@ -123,6 +103,7 @@ const AllUsers = () => { // Supporting data const [roles, setRoles] = useState([]); const [tenants, setTenants] = useState([]); + const [allUsersForCounts, setAllUsersForCounts] = useState([]); const [currentTenant, setCurrentTenant] = useState(null); const [isRoleLoading, setIsRoleLoading] = useState(false); @@ -132,6 +113,9 @@ const AllUsers = () => { const loadSupportingData = async () => { setIsRoleLoading(true); try { + const usersData = await usersApi.getAll(); + if (isMounted) setAllUsersForCounts(usersData); + const rolesData = await rolesApi.getAll(); if (isMounted) setRoles(rolesData); @@ -150,6 +134,7 @@ const AllUsers = () => { }; const loadUsers = async () => { + const requestId = ++latestUsersRequestRef.current; setIsLoading(true); setErrorMessage(""); @@ -158,21 +143,39 @@ const AllUsers = () => { page, page_size: pageSize, search: debouncedSearch || undefined, - status: statusFilter, + filter_names: nameFilter, + filter_emails: emailFilter, + statuses: statusFilter, + filter_tenant_ids: tenantFilter, + filter_role_ids: roleFilter, + sort_by: + activeSort.column === "first_name" + ? "name" + : activeSort.column === "email" + ? "email" + : activeSort.column === "status" + ? "status" + : activeSort.column === "tenant_id" + ? "tenant" + : activeSort.column === "role_id" + ? "role" + : undefined, + sort_order: activeSort.direction ?? undefined, }); - if (isMounted) { + if (isMounted && requestId === latestUsersRequestRef.current) { setUsers(response.items); setTotalRows(response.total); - setTotalPages(response.total_pages); } } catch (err) { - if (isMounted) { + if (isMounted && requestId === latestUsersRequestRef.current) { const msg = err instanceof Error ? err.message : "Unable to load users."; setErrorMessage(msg); } } finally { - if (isMounted) setIsLoading(false); + if (isMounted && requestId === latestUsersRequestRef.current) { + setIsLoading(false); + } } }; @@ -191,13 +194,18 @@ const AllUsers = () => { page, pageSize, debouncedSearch, + nameFilter, + emailFilter, statusFilter, + tenantFilter, + roleFilter, + activeSort, ]); // Reset page on search/filter change useEffect(() => { setPage(1); - }, [debouncedSearch, statusFilter]); + }, [debouncedSearch, nameFilter, emailFilter, statusFilter, tenantFilter, roleFilter, activeSort]); // Restore search focus useEffect(() => { @@ -307,6 +315,64 @@ const AllUsers = () => { [roles, editForm.tenant_id] ); + const nameCounts = useMemo(() => { + const counts = new Map(); + + allUsersForCounts.forEach((user) => { + const fullName = formatName(user.first_name, user.last_name); + if (!fullName) return; + counts.set(fullName, (counts.get(fullName) ?? 0) + 1); + }); + + return counts; + }, [allUsersForCounts]); + + const emailCounts = useMemo(() => { + const counts = new Map(); + + allUsersForCounts.forEach((user) => { + if (!user.email) return; + counts.set(user.email, (counts.get(user.email) ?? 0) + 1); + }); + + return counts; + }, [allUsersForCounts]); + + const statusCounts = useMemo(() => { + const counts: Record = { + active: 0, + inactive: 0, + }; + + allUsersForCounts.forEach((user) => { + counts[user.status] += 1; + }); + + return counts; + }, [allUsersForCounts]); + + const tenantCounts = useMemo(() => { + const counts = new Map(); + + allUsersForCounts.forEach((user) => { + if (!user.tenant_id) return; + counts.set(user.tenant_id, (counts.get(user.tenant_id) ?? 0) + 1); + }); + + return counts; + }, [allUsersForCounts]); + + const roleCounts = useMemo(() => { + const counts = new Map(); + + allUsersForCounts.forEach((user) => { + if (!user.role_id) return; + counts.set(user.role_id, (counts.get(user.role_id) ?? 0) + 1); + }); + + return counts; + }, [allUsersForCounts]); + const handleUpdate = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedUser) return; @@ -356,10 +422,43 @@ const AllUsers = () => { () => [ { key: "first_name", - header: t('columns.name'), + header: ( +
+ {t('columns.name')} + { + setPage(1); + setNameFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "first_name", direction)); + }} + options={buildColumnFilterOptions(nameCounts.entries())} + /> +
+ ), render: (row) => formatName(row.first_name, row.last_name), }, - { key: "email", header: t('columns.email') }, + { + key: "email", + header: ( +
+ {t('columns.email')} + { + setPage(1); + setEmailFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "email", direction)); + }} + options={buildColumnFilterOptions(emailCounts.entries())} + /> +
+ ), + }, { key: "phone_number", header: t('columns.phone'), @@ -367,18 +466,75 @@ const AllUsers = () => { }, { key: "status", - header: t('columns.status'), + header: ( +
+ {t('columns.status')} + { + setPage(1); + setStatusFilter(values as UserStatus[]); + setActiveSort(resolveColumnSortState(activeSort, "status", direction)); + }} + options={[ + { label: `${t('common:status.active')} (${statusCounts.active})`, value: "active" }, + { label: `${t('common:status.inactive')} (${statusCounts.inactive})`, value: "inactive"}, + ]} + /> +
+ ), searchable: false, render: (row) => , }, { key: "tenant_id", - header: t('columns.tenant'), + header: ( +
+ {t('columns.tenant')} + {hasAccess("superadmin.tenant.read") && tenants.length > 0 && ( + { + setPage(1); + setTenantFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction)); + }} + options={tenants.map((tenant) => ({ + label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`, + value: tenant.id, + }))} + /> + )} +
+ ), render: (row) => getTenantName(row.tenant_id), }, { key: "role_id", - header: t('columns.role'), + header: ( +
+ {t('columns.role')} + { + setPage(1); + setRoleFilter(values); + setActiveSort(resolveColumnSortState(activeSort, "role_id", direction)); + }} + options={roles.map((role) => ({ + label: `${role.role_name} (${roleCounts.get(role.id) ?? 0})`, + value: role.id, + }))} + /> +
+ ), render: (row) => getRoleName(row.role_id), }, @@ -409,28 +565,7 @@ const AllUsers = () => { ), }, ], - [getTenantName, getRoleName, openView, openEdit, openDelete, t] - ); - - const filterControls = ( - + [activeSort, emailCounts, emailFilter, getTenantName, getRoleName, hasAccess, nameCounts, nameFilter, openDelete, openEdit, openView, roleCounts, roleFilter, roles, statusCounts, statusFilter, t, tenantCounts, tenantFilter, tenants] ); return ( @@ -471,7 +606,6 @@ const AllUsers = () => { onPageSizeChange={setPageSize} onSearchChange={setSearch} searchInputRef={searchInputRef} - filterControls={filterControls} /> )} @@ -563,4 +697,4 @@ const AllUsers = () => { ); }; -export default AllUsers; \ No newline at end of file +export default AllUsers; diff --git a/src/components/custom/CustomColumnFilter.tsx b/src/components/custom/CustomColumnFilter.tsx new file mode 100644 index 0000000..285e994 --- /dev/null +++ b/src/components/custom/CustomColumnFilter.tsx @@ -0,0 +1,299 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, ChevronDown, Search } from "lucide-react"; +import CustomButton from "./CustomButton"; + +export type ColumnFilterOption = { + label: string; + value: string; +}; + +export type ColumnSortDirection = "asc" | "desc" | null; + +export type ColumnSortState = { + column: T | null; + direction: ColumnSortDirection; +}; + +export type ColumnFilterConfig = { + title: string; + options: ColumnFilterOption[]; + selectedValues: string[]; + sortDirection: ColumnSortDirection; + enableSearch?: boolean; + enableSelectAll?: boolean; +}; + +const getAllOptionValues = (options: ColumnFilterOption[]) => + options.map((option) => option.value); + +type CustomColumnFilterProps = ColumnFilterConfig & { + onApply: (nextValues: string[], nextSort: ColumnSortDirection) => void; +}; + +const CustomColumnFilter = ({ + title, + options, + selectedValues, + sortDirection, + onApply, + enableSearch = true, + enableSelectAll = true, +}: CustomColumnFilterProps) => { + const [open, setOpen] = useState(false); + const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); + const [searchTerm, setSearchTerm] = useState(""); + const [draftValues, setDraftValues] = useState(selectedValues); + const [draftSort, setDraftSort] = useState(sortDirection); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const handler = (event: MouseEvent) => { + if (buttonRef.current?.contains(event.target as Node)) return; + if (menuRef.current?.contains(event.target as Node)) return; + setOpen(false); + }; + + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const handleToggle = (event: React.MouseEvent) => { + event.stopPropagation(); + + if (open) { + setOpen(false); + return; + } + + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + const popoverWidth = Math.min(420, window.innerWidth - 32); + const preferredLeft = rect.left - 12; + const clampedLeft = Math.min( + Math.max(16, preferredLeft), + Math.max(16, window.innerWidth - popoverWidth - 16) + ); + setMenuPos({ top: rect.bottom + 8, left: clampedLeft }); + } + + setDraftValues(selectedValues.length > 0 ? selectedValues : getAllOptionValues(options)); + setDraftSort(sortDirection); + setSearchTerm(""); + setOpen(true); + }; + + const normalizedQuery = searchTerm.trim().toLowerCase(); + const filteredOptions = useMemo( + () => + enableSearch + ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery)) + : options, + [enableSearch, normalizedQuery, options] + ); + const shouldRenderOptionsSection = enableSelectAll || filteredOptions.length > 0 || options.length > 0; + + const allOptionValues = useMemo(() => getAllOptionValues(options), [options]); + const selectableValues = filteredOptions.map((option) => option.value); + const isAllSelected = + filteredOptions.length > 0 && + selectableValues.every((value) => draftValues.includes(value)); + + const toggleValue = (value: string) => { + setDraftValues((prev) => + prev.includes(value) + ? prev.filter((item) => item !== value) + : [...prev, value] + ); + }; + + const handleSelectAll = () => { + if (isAllSelected) { + setDraftValues((prev) => prev.filter((value) => !selectableValues.includes(value))); + return; + } + + setDraftValues((prev) => Array.from(new Set([...prev, ...selectableValues]))); + }; + + const handleClear = () => { + setDraftValues(allOptionValues); + setDraftSort(null); + setSearchTerm(""); + onApply([], null); + setOpen(false); + }; + + const handleApply = () => { + const filteredOptionValues = filteredOptions.map((option) => option.value); + const isDraftAllSelected = + draftValues.length === allOptionValues.length && + allOptionValues.every((value) => draftValues.includes(value)); + + const normalizedValues = + normalizedQuery && filteredOptionValues.length > 0 && isDraftAllSelected + ? filteredOptionValues + : isDraftAllSelected + ? [] + : draftValues; + + onApply(normalizedValues, draftSort); + setOpen(false); + }; + + const isActive = selectedValues.length > 0 || sortDirection !== null; + + return ( + <> + + {open && menuPos + ? createPortal( +
+
+ + +
+ + {enableSearch ? ( +
+

+ Filter +

+
+ + setSearchTerm(event.target.value)} + placeholder="Search values..." + className="h-10 w-full rounded-xl border border-slate-200 bg-white pl-9 pr-3 text-sm text-slate-700 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100" + /> +
+
+ ) : null} + + {shouldRenderOptionsSection ? ( +
+ {enableSelectAll ? ( + + ) : null} + + {filteredOptions.map((option) => ( + + ))} + + {filteredOptions.length === 0 && options.length > 0 ? ( +
+ No matching options +
+ ) : null} +
+ ) : null} + +
+ + + Apply + +
+
, + document.body + ) + : null} + + ); +}; + +export default CustomColumnFilter; diff --git a/src/components/custom/CustomColumnFilter.utils.ts b/src/components/custom/CustomColumnFilter.utils.ts new file mode 100644 index 0000000..17b6e5f --- /dev/null +++ b/src/components/custom/CustomColumnFilter.utils.ts @@ -0,0 +1,24 @@ +import type { + ColumnFilterOption, + ColumnSortDirection, + ColumnSortState, +} from "./CustomColumnFilter"; + +export const buildColumnFilterOptions = ( + entries: Iterable<[string, number]> +): ColumnFilterOption[] => + Array.from(entries).map(([value, count]) => ({ + label: `${value} (${count})`, + value, + })); + +export function resolveColumnSortState( + currentSort: ColumnSortState, + column: T, + direction: ColumnSortDirection +): ColumnSortState { + return { + column: direction ? column : currentSort.column === column ? null : currentSort.column, + direction: direction ?? (currentSort.column === column ? null : currentSort.direction), + }; +} diff --git a/src/components/custom/index.ts b/src/components/custom/index.ts index 28b8eb1..3bf5cd5 100644 --- a/src/components/custom/index.ts +++ b/src/components/custom/index.ts @@ -21,6 +21,7 @@ import CustomLoader from "./CustomLoader"; import CustomActionMenu, { CustomActionItem } from "./CustomActionMenu"; import CustomStatus from "./CustomStatus"; import { CustomPhoneInput } from "./CustomPhoneInput"; +import CustomColumnFilter from "./CustomColumnFilter"; export { CustomInput, @@ -46,4 +47,5 @@ export { CustomActionItem, CustomStatus, CustomPhoneInput, + CustomColumnFilter, }; diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 28f1c26..f01ee17 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -14,6 +14,8 @@ import enDashboard from './locales/en/dashboard.json'; import arDashboard from './locales/ar/dashboard.json'; import enModules from './locales/en/modules.json'; import arModules from './locales/ar/modules.json'; +import enLogs from './locales/en/logs.json'; +import arLogs from './locales/ar/logs.json'; import enTheme from './locales/en/theme.json'; import arTheme from './locales/ar/theme.json'; @@ -33,6 +35,7 @@ const resources = { profile: enProfile, dashboard: enDashboard, modules: enModules, + logs: enLogs, theme: enTheme, }, ar: { @@ -42,6 +45,7 @@ const resources = { profile: arProfile, dashboard: arDashboard, modules: arModules, + logs: arLogs, theme: arTheme, }, }; diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index f568cf7..bbf1607 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -7,6 +7,7 @@ "tenants": "المستأجرون", "users": "المستخدمون", "roles": "الأدوار", + "logs": "السجلات", "themes": "المظاهر", "profile": "الملف الشخصي", "settings": "الإعدادات", diff --git a/src/i18n/locales/ar/logs.json b/src/i18n/locales/ar/logs.json new file mode 100644 index 0000000..efbc315 --- /dev/null +++ b/src/i18n/locales/ar/logs.json @@ -0,0 +1,15 @@ +{ + "title": "سجل النظام", + "exportFileName": "audit_logs.csv", + "columns": { + "timestamp": "الطابع الزمني", + "ipAddress": "عنوان IP", + "performedBy": "تم التنفيذ بواسطة", + "description": "الوصف", + "action": "الإجراء", + "module": "الوحدة" + }, + "messages": { + "loadError": "تعذر تحميل السجلات" + } +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index fb28af2..f49e08d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -7,6 +7,7 @@ "tenants": "Tenants", "users": "Users", "roles": "Roles", + "logs": "Logs", "themes": "Themes", "profile": "Profile", "settings": "Settings", @@ -109,4 +110,4 @@ "error": "An error occurred. Please try again.", "confirmDelete": "Are you sure you want to delete this item?" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en/logs.json b/src/i18n/locales/en/logs.json new file mode 100644 index 0000000..66ad12f --- /dev/null +++ b/src/i18n/locales/en/logs.json @@ -0,0 +1,15 @@ +{ + "title": "System Logs", + "exportFileName": "audit_logs.csv", + "columns": { + "timestamp": "Timestamp", + "ipAddress": "IP Address", + "performedBy": "Performed By", + "description": "Description", + "action": "Action", + "module": "Module" + }, + "messages": { + "loadError": "Failed to fetch logs" + } +} diff --git a/src/lib/dateFormat.ts b/src/lib/dateFormat.ts new file mode 100644 index 0000000..b961b5c --- /dev/null +++ b/src/lib/dateFormat.ts @@ -0,0 +1,28 @@ +export const formatDate = ( + dateString?: string | null, + language: string = "en" +) => { + if (!dateString) return ""; + + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return dateString; + + const hasTime = dateString.includes("T") || dateString.includes(":"); + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", + }; + + if (hasTime) { + options.hour = "2-digit"; + options.minute = "2-digit"; + options.hour12 = false; + } + + return date.toLocaleString(language === "ar" ? "ar-EG" : "en-GB", options); + } catch { + return dateString; + } +}; diff --git a/src/lib/queryParams.ts b/src/lib/queryParams.ts new file mode 100644 index 0000000..839ee19 --- /dev/null +++ b/src/lib/queryParams.ts @@ -0,0 +1,21 @@ +export const buildQueryString = (params: Record): string => { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((item) => { + if (item !== undefined && item !== null && item !== "") { + searchParams.append(key, String(item)); + } + }); + return; + } + + if (value !== undefined && value !== null && value !== "") { + searchParams.append(key, String(value)); + } + }); + + const query = searchParams.toString(); + return query ? `?${query}` : ""; +};