filter functionality generic filter implementation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"tenants": "المستأجرون",
|
||||
"users": "المستخدمون",
|
||||
"roles": "الأدوار",
|
||||
"logs": "السجلات",
|
||||
"themes": "المظاهر",
|
||||
"profile": "الملف الشخصي",
|
||||
"settings": "الإعدادات",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"title": "سجل النظام",
|
||||
"exportFileName": "audit_logs.csv",
|
||||
"columns": {
|
||||
"timestamp": "الطابع الزمني",
|
||||
"ipAddress": "عنوان IP",
|
||||
"performedBy": "تم التنفيذ بواسطة",
|
||||
"description": "الوصف",
|
||||
"action": "الإجراء",
|
||||
"module": "الوحدة"
|
||||
},
|
||||
"messages": {
|
||||
"loadError": "تعذر تحميل السجلات"
|
||||
}
|
||||
}
|
||||
@@ -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?"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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}` : "";
|
||||
};
|
||||
Reference in New Issue
Block a user