filter functionality generic filter implementation

This commit is contained in:
amee
2026-04-17 10:35:19 +05:30
parent be63c7c07d
commit 7e8dab160b
20 changed files with 1341 additions and 326 deletions
@@ -55,45 +55,6 @@ export default function SignInForm() {
</p>
</div>
<div>
<div className="flex flex-col gap-4">
<button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-black transition-colors bg-white rounded-lg px-7 hover:bg-gray-50 hover:text-gray-900 w-full">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.7511 10.1944C18.7511 9.47495 18.6915 8.94995 18.5626 8.40552H10.1797V11.6527H15.1003C15.0011 12.4597 14.4654 13.675 13.2749 14.4916L13.2582 14.6003L15.9087 16.6126L16.0924 16.6305C17.7788 15.1041 18.7511 12.8583 18.7511 10.1944Z"
fill="#4285F4"
/>
<path
d="M10.1788 18.75C12.5895 18.75 14.6133 17.9722 16.0915 16.6305L13.274 14.4916C12.5201 15.0068 11.5081 15.3666 10.1788 15.3666C7.81773 15.3666 5.81379 13.8402 5.09944 11.7305L4.99473 11.7392L2.23868 13.8295L2.20264 13.9277C3.67087 16.786 6.68674 18.75 10.1788 18.75Z"
fill="#34A853"
/>
<path
d="M5.10014 11.7305C4.91165 11.186 4.80257 10.6027 4.80257 9.99992C4.80257 9.3971 4.91165 8.81379 5.09022 8.26935L5.08523 8.1534L2.29464 6.02954L2.20333 6.0721C1.5982 7.25823 1.25098 8.5902 1.25098 9.99992C1.25098 11.4096 1.5982 12.7415 2.20333 13.9277L5.10014 11.7305Z"
fill="#FBBC05"
/>
<path
d="M10.1789 4.63331C11.8554 4.63331 12.9864 5.34303 13.6312 5.93612L16.1511 3.525C14.6035 2.11528 12.5895 1.25 10.1789 1.25C6.68676 1.25 3.67088 3.21387 2.20264 6.07218L5.08953 8.26943C5.81381 6.15972 7.81776 4.63331 10.1789 4.63331Z"
fill="#EB4335"
/>
</svg>
Sign in with Google
</button>
</div>
<div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-700"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="p-2 text-gray-400 bg-slate-900 sm:px-5 sm:py-2">
Or
</span>
</div>
</div>
<form onSubmit={handleSignIn}>
<div className="space-y-6">
<CustomInput
+229 -38
View File
@@ -1,11 +1,19 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { History, RefreshCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CustomButton, CustomColumnFilter } from "../../components/custom";
import { DataTable } from "../../components/custom/CustomTable";
import type { ColumnDef } from '../../components/custom/CustomTable';
import CustomButton from "../../components/custom/CustomButton";
import type { ColumnDef } from "../../components/custom/CustomTable";
import {
buildColumnFilterOptions,
resolveColumnSortState,
} from "../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../components/custom/CustomColumnFilter";
import { useDebounce } from "../../components/hooks/useDebounce";
import { apiClient } from "../../lib/apiClient";
import { buildQueryString } from "../../lib/queryParams";
interface AuditLog extends Record<string, any> {
interface AuditLog extends Record<string, unknown> {
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<AuditLog[]>([]);
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
const [actionFilter, setActionFilter] = useState<string[]>([]);
const [emailFilter, setEmailFilter] = useState<string[]>([]);
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<HTMLInputElement>(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<AuditLogListResponse>(
'/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<AuditLogListResponse>(
"/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<AuditLog>[] = [
{ key: "module_name", header: "Module", searchable: true },
{
key: "action_type",
header: "Action",
render: (row) => (
<span className={`px-2 py-1 rounded text-xs font-bold ${row.action_type === 'CREATE' ? 'bg-green-100 text-green-700' :
row.action_type === 'UPDATE' ? 'bg-blue-100 text-blue-700' :
row.action_type === 'DELETE' ? 'bg-red-100 text-red-700' : 'bg-gray-100'
}`}>
{row.action_type}
</span>
)
},
{ 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<string, number>();
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<string, number>();
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<string, number>();
allLogsForCounts.forEach((log) => {
counts.set(log.performed_by_email, (counts.get(log.performed_by_email) ?? 0) + 1);
});
return counts;
}, [allLogsForCounts]);
const columns: ColumnDef<AuditLog>[] = useMemo(
() => [
{
key: "module_name",
header: (
<div className="flex items-center">
{t("columns.module")}
<CustomColumnFilter
title={t("columns.module")}
options={buildColumnFilterOptions(moduleCounts.entries())}
selectedValues={moduleFilter}
sortDirection={activeSort.column === "module_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setModuleFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "module_name", direction));
}}
/>
</div>
),
searchable: false,
},
{
key: "action_type",
header: (
<div className="flex items-center">
{t("columns.action")}
<CustomColumnFilter
title={t("columns.action")}
options={buildColumnFilterOptions(actionCounts.entries())}
selectedValues={actionFilter}
sortDirection={activeSort.column === "action_type" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setActionFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "action_type", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<span
className={`rounded px-2 py-1 text-xs font-bold ${
row.action_type === "CREATE"
? "bg-green-100 text-green-700"
: row.action_type === "UPDATE"
? "bg-blue-100 text-blue-700"
: row.action_type === "DELETE"
? "bg-red-100 text-red-700"
: "bg-gray-100"
}`}
>
{row.action_type}
</span>
),
},
{ key: "description", header: t("columns.description") },
{
key: "performed_by_email",
header: (
<div className="flex items-center">
{t("columns.performedBy")}
<CustomColumnFilter
title={t("columns.performedBy")}
options={buildColumnFilterOptions(emailCounts.entries())}
selectedValues={emailFilter}
sortDirection={activeSort.column === "performed_by_email" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setEmailFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "performed_by_email", direction));
}}
/>
</div>
),
searchable: false,
},
{ key: "ip_address", header: t("columns.ipAddress") },
{
key: "created_at",
header: (
<div className="flex items-center">
{t("columns.timestamp")}
<CustomColumnFilter
title={t("columns.timestamp")}
options={[]}
selectedValues={[]}
sortDirection={activeSort.column === "created_at" ? activeSort.direction : null}
enableSearch={false}
enableSelectAll={false}
onApply={(_, direction) => {
setPage(1);
setActiveSort(resolveColumnSortState(activeSort, "created_at", direction));
}}
/>
</div>
),
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 (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-(--text-primary) flex items-center gap-2">
<History size={24} /> System Logs
<History size={24} /> {t("title")}
</h1>
<CustomButton
variant="outlined"
@@ -82,7 +263,7 @@ const LogsPage = () => {
loading={isLoading}
leftIcon={<RefreshCcw size={16} />}
>
Refresh
{t("common:actions.refresh")}
</CustomButton>
</div>
@@ -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}
/>
</div>
);
};
export default LogsPage;
export default LogsPage;
+9 -11
View File
@@ -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, any>): 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<Role[]>("/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<RolePaginatedResponse>(`/api/role/list${queryString}`);
},
+108 -33
View File
@@ -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<string[]>([]);
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
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<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestRolesRequestRef = useRef(0);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
@@ -83,6 +72,7 @@ const AllRoles = () => {
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [allRolesForCounts, setAllRolesForCounts] = useState<Role[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(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<string, number>();
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<string, number>();
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<Array<ColumnDef<Role>>>(
() => [
{ key: "role_name", header: t('columns.roleName') },
{
key: "role_name",
header: (
<div className="flex items-center">
{t('columns.roleName')}
<CustomColumnFilter
title={t('columns.roleName')}
options={buildColumnFilterOptions(roleNameCounts.entries())}
selectedValues={roleNameFilter}
sortDirection={activeSort.column === "role_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setRoleNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "role_name", direction));
}}
/>
</div>
),
},
{
key: "tenant_id",
header: t('columns.tenant'),
header: (
<div className="flex items-center">
{t('columns.tenant')}
{canReadAllTenants ? (
<CustomColumnFilter
title={t('columns.tenant')}
options={tenants.map((tenant) => ({
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}
</div>
),
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;
export default AllRoles;
@@ -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, any>): 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<SubscriptionPlanPaginatedResponse>(
`/api/subscription-plan/list${queryString}`
@@ -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<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [visibilityFilter, setVisibilityFilter] = useState<boolean[]>([]);
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<SubscriptionPlan[]>([]);
const debouncedSearch = useDebounce(search, 500);
const searchInputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestPlansRequestRef = useRef(0);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(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<string, number>();
allPlansForCounts.forEach((plan) => {
counts.set(plan.name, (counts.get(plan.name) ?? 0) + 1);
});
return counts;
}, [allPlansForCounts]);
const planStatusCounts = useMemo(() => {
const counts = new Map<string, number>();
allPlansForCounts.forEach((plan) => {
counts.set(plan.status, (counts.get(plan.status) ?? 0) + 1);
});
return counts;
}, [allPlansForCounts]);
const visibilityCounts = useMemo(() => {
const counts = new Map<string, number>();
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<Array<ColumnDef<SubscriptionPlan>>>(
() => [
{ key: "name", header: "Plan Name" },
{
key: "name",
header: (
<div className="flex items-center">
Plan Name
<CustomColumnFilter
title="Plan Name"
options={buildColumnFilterOptions(nameCounts.entries())}
selectedValues={nameFilter}
sortDirection={activeSort.column === "name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "name", direction));
}}
/>
</div>
),
},
{
key: "price",
header: "Price",
@@ -333,7 +383,26 @@ const AllSubscriptions = () => {
},
{
key: "status",
header: "Status",
header: (
<div className="flex items-center">
Status
<CustomColumnFilter
title="Status"
options={[
{ label: `Active (${planStatusCounts.get("active") ?? 0})`, value: "active" },
{ label: `Inactive (${planStatusCounts.get("inactive") ?? 0})`, value: "inactive" },
]}
selectedValues={statusFilter}
sortDirection={activeSort.column === "status" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
setPage(1);
setStatusFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "status", direction));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<CustomStatus
@@ -343,7 +412,26 @@ const AllSubscriptions = () => {
},
{
key: "is_public",
header: "Visibility",
header: (
<div className="flex items-center">
Visibility
<CustomColumnFilter
title="Visibility"
options={[
{ label: `Public (${visibilityCounts.get("true") ?? 0})`, value: "true" },
{ label: `Private (${visibilityCounts.get("false") ?? 0})`, value: "false" },
]}
selectedValues={visibilityFilter.map((value) => 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));
}}
/>
</div>
),
searchable: false,
render: (row) => (
<span
@@ -396,7 +484,18 @@ const AllSubscriptions = () => {
),
},
],
[openView, openEdit, openDelete]
[
activeSort,
nameCounts,
nameFilter,
openDelete,
openEdit,
openView,
planStatusCounts,
statusFilter,
visibilityCounts,
visibilityFilter,
]
);
return (
@@ -663,4 +762,4 @@ const AllSubscriptions = () => {
);
};
export default AllSubscriptions;
export default AllSubscriptions;
+13 -12
View File
@@ -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, any>): 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<Tenant[]>("/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<TenantPaginatedResponse>(
`/api/tenant/list${queryString}`
+204 -60
View File
@@ -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<string[]>([]);
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
const [planFilter, setPlanFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<boolean[]>([]);
const [totalRows, setTotalRows] = useState(0);
const [, setTotalPages] = useState(0);
const [statusFilter, setStatusFilter] = useState<boolean | null>(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<Tenant[]>([]);
const searchInputRef = useRef<HTMLInputElement>(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<string, number>();
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<string, number>();
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<string, number>();
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<string, number>();
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<Array<ColumnDef<Tenant>>>(
() => [
{ key: "tenant_name", header: "Tenant Name" },
{ key: "tenant_domain", header: "Domain" },
{
key: "tenant_name",
header: (
<div className="flex items-center">
Tenant Name
{canReadAll ? (
<CustomColumnFilter
title="Tenant Name"
options={buildColumnFilterOptions(tenantNameCounts.entries())}
selectedValues={tenantNameFilter}
sortDirection={activeSort.column === "tenant_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setTenantNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "tenant_name", direction));
}}
/>
) : null}
</div>
),
},
{
key: "tenant_domain",
header: (
<div className="flex items-center">
Domain
{canReadAll ? (
<CustomColumnFilter
title="Domain"
options={buildColumnFilterOptions(tenantDomainCounts.entries())}
selectedValues={tenantDomainFilter}
sortDirection={activeSort.column === "tenant_domain" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setTenantDomainFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "tenant_domain", direction));
}}
/>
) : null}
</div>
),
},
{
key: "tenant_logo_url",
header: "Logo",
@@ -360,7 +466,27 @@ const AllTenants = () => {
},
{
key: "plan_id",
header: "Plan",
header: (
<div className="flex items-center">
Plan
{canReadAll && plans.length > 0 ? (
<CustomColumnFilter
title="Plan"
options={plans.map((plan) => ({
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}
</div>
),
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
@@ -370,7 +496,28 @@ const AllTenants = () => {
},
{
key: "is_active",
header: "Status",
header: (
<div className="flex items-center">
Status
{canReadAll ? (
<CustomColumnFilter
title="Status"
options={[
{ label: `Active (${tenantStatusCounts.get("true") ?? 0})`, value: "true" },
{ label: `Inactive (${tenantStatusCounts.get("false") ?? 0})`, value: "false" },
]}
selectedValues={statusFilter.map((value) => 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}
</div>
),
searchable: false,
render: (row) => (
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
@@ -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 ? (
<select
value={statusFilter === null ? "all" : statusFilter ? "active" : "inactive"}
onChange={(e) => {
const value = e.target.value;
setStatusFilter(
value === "all" ? null : value === "active" ? true : false
);
}}
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
>
<option value="all" className="bg-(--card-bg) text-(--text-primary)">All Statuses</option>
<option value="active" className="bg-(--card-bg) text-(--text-primary)">Active</option>
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">Inactive</option>
</select>
) : null;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
@@ -478,7 +623,6 @@ const AllTenants = () => {
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
filterControls={filterControls}
/>
)}
@@ -672,4 +816,4 @@ const AllTenants = () => {
);
};
export default AllTenants;
export default AllTenants;
+15 -13
View File
@@ -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, any>): 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<UserPaginatedResponse>(`/api/user/list${queryString}`);
},
+207 -73
View File
@@ -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<UserStatus | null>(null);
const [nameFilter, setNameFilter] = useState<string[]>([]);
const [emailFilter, setEmailFilter] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<UserStatus[]>([]);
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
const [roleFilter, setRoleFilter] = useState<string[]>([]);
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<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const latestUsersRequestRef = useRef(0);
// Modals
const [selectedUser, setSelectedUser] = useState<User | null>(null);
@@ -123,6 +103,7 @@ const AllUsers = () => {
// Supporting data
const [roles, setRoles] = useState<Role[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [allUsersForCounts, setAllUsersForCounts] = useState<User[]>([]);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(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<string, number>();
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<string, number>();
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<UserStatus, number> = {
active: 0,
inactive: 0,
};
allUsersForCounts.forEach((user) => {
counts[user.status] += 1;
});
return counts;
}, [allUsersForCounts]);
const tenantCounts = useMemo(() => {
const counts = new Map<string, number>();
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<string, number>();
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: (
<div className="flex items-center">
{t('columns.name')}
<CustomColumnFilter
title={t('columns.name')}
selectedValues={nameFilter}
sortDirection={activeSort.column === "first_name" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setNameFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "first_name", direction));
}}
options={buildColumnFilterOptions(nameCounts.entries())}
/>
</div>
),
render: (row) => formatName(row.first_name, row.last_name),
},
{ key: "email", header: t('columns.email') },
{
key: "email",
header: (
<div className="flex items-center">
{t('columns.email')}
<CustomColumnFilter
title={t('columns.email')}
selectedValues={emailFilter}
sortDirection={activeSort.column === "email" ? activeSort.direction : null}
onApply={(values, direction) => {
setPage(1);
setEmailFilter(values);
setActiveSort(resolveColumnSortState(activeSort, "email", direction));
}}
options={buildColumnFilterOptions(emailCounts.entries())}
/>
</div>
),
},
{
key: "phone_number",
header: t('columns.phone'),
@@ -367,18 +466,75 @@ const AllUsers = () => {
},
{
key: "status",
header: t('columns.status'),
header: (
<div className="flex items-center">
{t('columns.status')}
<CustomColumnFilter
title={t('columns.status')}
selectedValues={statusFilter}
sortDirection={activeSort.column === "status" ? activeSort.direction : null}
enableSearch={false}
onApply={(values, direction) => {
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"},
]}
/>
</div>
),
searchable: false,
render: (row) => <CustomStatus status={row.status} />,
},
{
key: "tenant_id",
header: t('columns.tenant'),
header: (
<div className="flex items-center">
{t('columns.tenant')}
{hasAccess("superadmin.tenant.read") && tenants.length > 0 && (
<CustomColumnFilter
title={t('columns.tenant')}
selectedValues={tenantFilter}
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
onApply={(values, direction) => {
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,
}))}
/>
)}
</div>
),
render: (row) => getTenantName(row.tenant_id),
},
{
key: "role_id",
header: t('columns.role'),
header: (
<div className="flex items-center">
{t('columns.role')}
<CustomColumnFilter
title={t('columns.role')}
selectedValues={roleFilter}
sortDirection={activeSort.column === "role_id" ? activeSort.direction : null}
onApply={(values, direction) => {
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,
}))}
/>
</div>
),
render: (row) => getRoleName(row.role_id),
},
@@ -409,28 +565,7 @@ const AllUsers = () => {
),
},
],
[getTenantName, getRoleName, openView, openEdit, openDelete, t]
);
const filterControls = (
<select
value={statusFilter === null ? "all" : statusFilter}
onChange={(e) => {
const val = e.target.value as "all" | "active" | "inactive";
setStatusFilter(val === "all" ? null : val);
}}
className="rounded-md border border-(--card-border) bg-(--card-bg) text-(--text-primary) text-sm py-1.5 px-2 focus:outline-none focus:ring focus:ring-blue-600"
>
<option value="all" className="bg-(--card-bg) text-(--text-primary)">
{t('common:common.allStatuses', { defaultValue: 'All Statuses' })}
</option>
<option value="active" className="bg-(--card-bg) text-(--text-primary)">
{t('common:status.active')}
</option>
<option value="inactive" className="bg-(--card-bg) text-(--text-primary)">
{t('common:status.inactive')}
</option>
</select>
[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;
export default AllUsers;
@@ -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<T extends string = string> = {
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<string[]>(selectedValues);
const [draftSort, setDraftSort] = useState<ColumnSortDirection>(sortDirection);
const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(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 (
<>
<button
type="button"
ref={buttonRef}
onClick={handleToggle}
className={`ml-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors ${
isActive
? "border-blue-200 bg-blue-50 text-blue-600"
: "border-transparent text-slate-400 hover:border-slate-200 hover:bg-slate-100"
}`}
aria-label={`${title} filter`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<path
d="M4 6h16M7 12h10M10 18h4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
<ChevronDown size={12} />
</button>
{open && menuPos
? createPortal(
<div
ref={menuRef}
style={{ position: "fixed", top: menuPos.top, left: menuPos.left, zIndex: 9999 }}
className="w-[420px] max-w-[calc(100vw-32px)] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_24px_60px_rgba(15,23,42,0.18)]"
>
<div className="border-b border-slate-200 py-2">
<button
type="button"
onClick={() => setDraftSort("asc")}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-slate-50 ${
draftSort === "asc" ? "bg-blue-50 text-blue-600" : "text-slate-700"
}`}
>
<span className="flex h-5 w-5 items-center justify-center rounded border border-slate-200 bg-white text-slate-500">
</span>
<span>Sort Ascending</span>
</button>
<button
type="button"
onClick={() => setDraftSort("desc")}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-slate-50 ${
draftSort === "desc" ? "bg-blue-50 text-blue-600" : "text-slate-700"
}`}
>
<span className="flex h-5 w-5 items-center justify-center rounded border border-slate-200 bg-white text-slate-500">
</span>
<span>Sort Descending</span>
</button>
</div>
{enableSearch ? (
<div className="border-b border-slate-200 px-4 py-3">
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
Filter
</p>
<div className="relative">
<Search
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
/>
<input
value={searchTerm}
onChange={(event) => 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"
/>
</div>
</div>
) : null}
{shouldRenderOptionsSection ? (
<div className="max-h-[260px] overflow-y-auto py-2">
{enableSelectAll ? (
<button
type="button"
onClick={handleSelectAll}
className="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className={`flex h-[18px] w-[18px] items-center justify-center rounded border transition-colors ${
isAllSelected
? "border-blue-500 bg-blue-500 text-white"
: "border-slate-300 bg-white text-transparent"
}`}
>
<Check size={12} strokeWidth={3} />
</span>
<span className="truncate font-medium text-slate-900">Select All</span>
</button>
) : null}
{filteredOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => toggleValue(option.value)}
className="flex w-full items-start gap-3 px-4 py-2 text-left text-sm text-slate-700 transition-colors hover:bg-slate-50"
>
<span
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border transition-colors ${
draftValues.includes(option.value)
? "border-blue-500 bg-blue-500 text-white"
: "border-slate-300 bg-white text-transparent"
}`}
>
<Check size={12} strokeWidth={3} />
</span>
<span className="whitespace-normal break-words leading-5">{option.label}</span>
</button>
))}
{filteredOptions.length === 0 && options.length > 0 ? (
<div className="px-4 py-6 text-center text-sm text-slate-400">
No matching options
</div>
) : null}
</div>
) : null}
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-3">
<button
type="button"
onClick={handleClear}
className="text-sm font-medium text-slate-500 transition hover:text-slate-700"
>
Clear
</button>
<CustomButton
variant="primary"
onClick={handleApply}
className="!h-9 !rounded-xl !px-4 !text-sm"
>
Apply
</CustomButton>
</div>
</div>,
document.body
)
: null}
</>
);
};
export default CustomColumnFilter;
@@ -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<T extends string>(
currentSort: ColumnSortState<T>,
column: T,
direction: ColumnSortDirection
): ColumnSortState<T> {
return {
column: direction ? column : currentSort.column === column ? null : currentSort.column,
direction: direction ?? (currentSort.column === column ? null : currentSort.direction),
};
}
+2
View File
@@ -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,
};
+4
View File
@@ -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,
},
};
+1
View File
@@ -7,6 +7,7 @@
"tenants": "المستأجرون",
"users": "المستخدمون",
"roles": "الأدوار",
"logs": "السجلات",
"themes": "المظاهر",
"profile": "الملف الشخصي",
"settings": "الإعدادات",
+15
View File
@@ -0,0 +1,15 @@
{
"title": "سجل النظام",
"exportFileName": "audit_logs.csv",
"columns": {
"timestamp": "الطابع الزمني",
"ipAddress": "عنوان IP",
"performedBy": "تم التنفيذ بواسطة",
"description": "الوصف",
"action": "الإجراء",
"module": "الوحدة"
},
"messages": {
"loadError": "تعذر تحميل السجلات"
}
}
+2 -1
View File
@@ -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?"
}
}
}
+15
View File
@@ -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"
}
}
+28
View File
@@ -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;
}
};
+21
View File
@@ -0,0 +1,21 @@
export const buildQueryString = (params: Record<string, unknown>): 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}` : "";
};