();
+
+ 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}` : "";
+};