From 51bac652dfacc7019d1e13e8dcbe5dd726e5efc3 Mon Sep 17 00:00:00 2001 From: amee Date: Sat, 18 Apr 2026 16:31:52 +0530 Subject: [PATCH 1/2] feat: tenant-subscription fix: deleted updatemodule --- .../modules/admin/components/ModuleList.tsx | 2 +- .../subscriptions/SubscriptionTypes.ts | 3 + .../components/AddSubscriptions.tsx | 15 ++ .../components/AllSubscriptions.tsx | 35 +++++ src/application/tenants/TenantsTypes.ts | 12 ++ .../tenants/components/AddTenants.tsx | 58 +++++++- .../tenants/components/AllTenants.tsx | 139 ++++++++++++++++-- src/components/layout/AppSidebar.tsx | 40 ++--- 8 files changed, 258 insertions(+), 46 deletions(-) diff --git a/src/application/modules/admin/components/ModuleList.tsx b/src/application/modules/admin/components/ModuleList.tsx index 981001e..b9ca4db 100644 --- a/src/application/modules/admin/components/ModuleList.tsx +++ b/src/application/modules/admin/components/ModuleList.tsx @@ -12,7 +12,7 @@ import CustomButton from '../../../../components/custom/CustomButton'; const ModuleList = () => { const navigate = useNavigate(); const { t } = useTranslation(['modules', 'common']); - const { listModules, deleteModule, updateModule, loading } = useModuleApi(); + const { listModules, deleteModule, loading } = useModuleApi(); const [modules, setModules] = useState([]); const [showForm, setShowForm] = useState(false); const [selectedModule, setSelectedModule] = useState(null); diff --git a/src/application/subscriptions/SubscriptionTypes.ts b/src/application/subscriptions/SubscriptionTypes.ts index fa81502..4c75ea7 100644 --- a/src/application/subscriptions/SubscriptionTypes.ts +++ b/src/application/subscriptions/SubscriptionTypes.ts @@ -3,6 +3,7 @@ export type SubscriptionPlan = { name: string; description?: string | null; price?: number | null; + duration_days?: number | null; is_public: boolean; status: string; created_at: string; @@ -18,6 +19,7 @@ export type SubscriptionPlanCreateRequest = { name: string; description?: string; price?: number; + duration_days?: number; is_public?: boolean; status?: string; access_ids?: string[]; @@ -28,6 +30,7 @@ export type SubscriptionPlanUpdateRequest = { name?: string; description?: string; price?: number; + duration_days?: number; is_public?: boolean; status?: string; access_ids?: string[]; diff --git a/src/application/subscriptions/components/AddSubscriptions.tsx b/src/application/subscriptions/components/AddSubscriptions.tsx index 8a8f3f9..23db135 100644 --- a/src/application/subscriptions/components/AddSubscriptions.tsx +++ b/src/application/subscriptions/components/AddSubscriptions.tsx @@ -19,6 +19,7 @@ const AddSubscriptions = () => { name: "", description: "", price: undefined, + duration_days: undefined, is_public: true, status: "active", access_ids: [], @@ -96,6 +97,7 @@ const AddSubscriptions = () => { name: formData.name.trim(), description: formData.description?.trim() || undefined, price: formData.price ? Number(formData.price) : undefined, + duration_days: formData.duration_days ? Number(formData.duration_days) : undefined, is_public: formData.is_public, status: formData.status, access_ids: accessIds, @@ -157,6 +159,19 @@ const AddSubscriptions = () => { })) } /> + + setFormData((prev) => ({ + ...prev, + duration_days: e.target.value ? Number(e.target.value) : undefined, + })) + } + /> { name: "", description: "", price: undefined, + duration_days: undefined, is_public: true, status: "active", }); @@ -249,6 +251,7 @@ const AllSubscriptions = () => { name: plan.name, description: plan.description ?? "", price: plan.price ?? undefined, + duration_days: plan.duration_days ?? undefined, is_public: plan.is_public, status: plan.status, }); @@ -311,6 +314,7 @@ const AllSubscriptions = () => { name: editForm.name.trim(), description: editForm.description.trim() || undefined, price: editForm.price ? Number(editForm.price) : undefined, + duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined, is_public: editForm.is_public, status: editForm.status, access_ids: accessIds, @@ -381,6 +385,16 @@ const AllSubscriptions = () => { ), }, + { + key: "duration_days", + header: "Duration", + searchable: false, + render: (row) => ( + + {row.duration_days ? `${row.duration_days} days` : "-"} + + ), + }, { key: "status", header: ( @@ -570,6 +584,14 @@ const AllSubscriptions = () => { {formatPrice(selectedPlan.price)}

+
+

+ Duration +

+

+ {selectedPlan.duration_days ? `${selectedPlan.duration_days} days` : "-"} +

+

Status @@ -689,6 +711,19 @@ const AllSubscriptions = () => { })) } /> + + setEditForm((prev) => ({ + ...prev, + duration_days: e.target.value ? Number(e.target.value) : undefined, + })) + } + />

value.toISOString().slice(0, 10); + +const addDays = (dateValue: string, days?: number | null) => { + if (!dateValue || !days) return ""; + const nextDate = new Date(`${dateValue}T00:00:00`); + nextDate.setDate(nextDate.getDate() + days); + return toIsoDate(nextDate); +}; + const AddTenants = () => { + const today = new Date().toISOString().slice(0, 10); const { hasAccess, isLoading: isAuthLoading } = useAuth(); const canCreateTenant = hasAccess("superadmin.tenant.create"); const navigate = useNavigate(); @@ -22,6 +32,9 @@ const AddTenants = () => { const [tenantDomain, setTenantDomain] = useState(""); const [tenantLogoUrl, setTenantLogoUrl] = useState(""); const [selectedPlanId, setSelectedPlanId] = useState(""); + const [startDate, setStartDate] = useState(today); + const [endDate, setEndDate] = useState(""); + const [tenantStatus, setTenantStatus] = useState("ACTIVE"); const [moduleEnvAssignments, setModuleEnvAssignments] = useState([]); const [plans, setPlans] = useState([]); @@ -43,6 +56,15 @@ const AddTenants = () => { const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(""); + useEffect(() => { + if (!selectedPlanId) return; + const selectedPlan = plans.find((plan) => plan.id === selectedPlanId); + if (!selectedPlan) return; + + setStartDate((prev) => prev || today); + setEndDate(addDays(startDate || today, selectedPlan.duration_days)); + }, [plans, selectedPlanId, startDate, today]); + useEffect(() => { if (isAuthLoading || canCreateTenant) { setIsTenantLoading(false); @@ -195,6 +217,9 @@ const AddTenants = () => { tenant_domain: tenantDomain.trim(), tenant_logo_url: tenantLogoUrl.trim() || undefined, plan_id: selectedPlanId, + start_date: startDate || undefined, + end_date: endDate || undefined, + status: tenantStatus, module_environments: moduleEnvAssignments.filter((a) => a.environment_slug), }; @@ -313,6 +338,35 @@ const AddTenants = () => { /> +
+ { + const nextStartDate = e.target.value; + setStartDate(nextStartDate); + const selectedPlan = plans.find((plan) => plan.id === selectedPlanId); + setEndDate(addDays(nextStartDate, selectedPlan?.duration_days)); + }} + /> + setEndDate(e.target.value)} + min={startDate || undefined} + /> + setTenantStatus(e.target.value as TenantStatus)} + options={[ + { label: "Active", value: "ACTIVE" }, + { label: "Inactive", value: "INACTIVE" }, + { label: "Expired", value: "EXPIRED" }, + ]} + /> +
+ {/* Module Environment Assignment — appears after plan selection */} {selectedPlanId && (
diff --git a/src/application/tenants/components/AllTenants.tsx b/src/application/tenants/components/AllTenants.tsx index ef8d7cd..15cccb2 100644 --- a/src/application/tenants/components/AllTenants.tsx +++ b/src/application/tenants/components/AllTenants.tsx @@ -6,6 +6,7 @@ import { CustomCheckBox, CustomColumnFilter, CustomConfirmationModal, + CustomDatePicker, CustomDropdown, CustomInput, CustomModal, @@ -22,12 +23,21 @@ import { } from "../../../components/custom/CustomColumnFilter.utils"; import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter"; import { formatDate } from "../../../lib/dateFormat"; -import type { Tenant, TenantUpdateRequest } from "../TenantsTypes"; +import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes"; import { tenantsApi } from "../TenantsApi"; import { useAuth } from "../../../context/AuthContext"; import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi"; import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes"; +const toIsoDate = (value: Date) => value.toISOString().slice(0, 10); + +const addDays = (dateValue: string, days?: number | null) => { + if (!dateValue || !days) return ""; + const nextDate = new Date(`${dateValue}T00:00:00`); + nextDate.setDate(nextDate.getDate() + days); + return toIsoDate(nextDate); +}; + function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { @@ -58,6 +68,9 @@ interface EditFormState { tenant_logo_url: string; is_active: boolean; plan_id: string; + start_date: string; + end_date: string; + status: TenantStatus; } const AllTenants = () => { @@ -77,11 +90,13 @@ const AllTenants = () => { tenant_logo_url: "", is_active: true, plan_id: "", + start_date: "", + end_date: "", + status: "ACTIVE", }); // Subscription plans for dropdown const [plans, setPlans] = useState([]); - const [isPlansLoading, setIsPlansLoading] = useState(false); const [editError, setEditError] = useState(""); const [deleteError, setDeleteError] = useState(""); @@ -224,14 +239,11 @@ const AllTenants = () => { let isMounted = true; const loadPlans = async () => { - setIsPlansLoading(true); try { const data = await subscriptionsApi.getAll(); if (isMounted) setPlans(data); } catch (error) { console.error("Failed to load subscription plans:", error); - } finally { - if (isMounted) setIsPlansLoading(false); } }; @@ -298,6 +310,9 @@ const AllTenants = () => { tenant_logo_url: tenant.tenant_logo_url ?? "", is_active: tenant.is_active, plan_id: tenant.plan_id ?? "", + start_date: tenant.start_date ?? "", + end_date: tenant.end_date ?? "", + status: tenant.status, }); }, []); @@ -355,6 +370,9 @@ const AllTenants = () => { tenant_logo_url: editForm.tenant_logo_url.trim() || null, is_active: editForm.is_active, plan_id: editForm.plan_id || undefined, + start_date: editForm.start_date || null, + end_date: editForm.end_date || null, + status: editForm.status, }; const updatedTenant = await tenantsApi.update(tenantId, payload); @@ -520,7 +538,27 @@ const AllTenants = () => { ), searchable: false, render: (row) => ( - + + ), + }, + { + key: "start_date", + header: "Start Date", + searchable: false, + render: (row) => ( +
+ {row.start_date ? formatDate(row.start_date) : "-"} +
+ ), + }, + { + key: "end_date", + header: "End Date", + searchable: false, + render: (row) => ( +
+ {row.end_date ? formatDate(row.end_date) : "-"} +
), }, { @@ -669,13 +707,19 @@ const AllTenants = () => {

)}
+
+

+ Tenant ID +

+

+ {selectedTenant.tenant_id} +

+

Status

-

- {selectedTenant.is_active ? "Active" : "Inactive"} -

+

@@ -685,6 +729,22 @@ const AllTenants = () => { {getPlanName(selectedTenant.plan_id)}

+
+

+ Start Date +

+

+ {selectedTenant.start_date ? formatDate(selectedTenant.start_date) : "-"} +

+
+
+

+ End Date +

+

+ {selectedTenant.end_date ? formatDate(selectedTenant.end_date) : "-"} +

+

Created @@ -780,15 +840,64 @@ const AllTenants = () => { label="Select Plan" name="plan_id" value={editForm.plan_id} - onChange={(e) => - setEditForm((prev) => ({ ...prev, plan_id: e.target.value })) - } + onChange={(e) => { + const nextPlanId = e.target.value; + const selectedPlan = plans.find((plan) => plan.id === nextPlanId); + const nextStartDate = editForm.start_date || toIsoDate(new Date()); + + setEditForm((prev) => ({ + ...prev, + plan_id: nextPlanId, + start_date: nextStartDate, + end_date: addDays(nextStartDate, selectedPlan?.duration_days), + })); + }} options={plans.map((plan) => ({ - label: `${plan.name}${plan.price != null ? ` — ${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`, + label: plan.duration_days + ? `${plan.name} (${plan.duration_days} days)` + : plan.name, value: plan.id, }))} - placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"} - disabled={isPlansLoading} + placeholder="Select a subscription plan" + /> +

+ +
+ { + const nextStartDate = e.target.value; + const selectedPlan = plans.find((plan) => plan.id === editForm.plan_id); + setEditForm((prev) => ({ + ...prev, + start_date: nextStartDate, + end_date: addDays(nextStartDate, selectedPlan?.duration_days), + })); + }} + /> + + setEditForm((prev) => ({ ...prev, end_date: e.target.value })) + } + /> + + setEditForm((prev) => ({ + ...prev, + status: e.target.value as TenantStatus, + })) + } + options={[ + { label: "Active", value: "ACTIVE" }, + { label: "Inactive", value: "INACTIVE" }, + { label: "Expired", value: "EXPIRED" }, + ]} />
diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index d992099..3f3d0e7 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { @@ -146,6 +146,16 @@ const AppSidebar: React.FC = () => { [location.pathname] ); + const isSubmenuRouteActive = useCallback( + (submenu?: SubMenuItem[]) => + submenu?.some( + (subItem) => + location.pathname === subItem.path || + location.pathname.startsWith(subItem.path + "/") + ) ?? false, + [location.pathname] + ); + const visibleNavItems = useMemo( () => navItems.filter((item) => { if (!item.access) return true; @@ -161,32 +171,6 @@ const AppSidebar: React.FC = () => { const [openSubmenus, setOpenSubmenus] = useState>({}); - useEffect(() => { - const currentPath = location.pathname; - - setOpenSubmenus(prev => { - const newState = { ...prev }; - - Object.keys(newState).forEach(menuName => { - if (newState[menuName]) { - const navItem = navItems.find(item => item.name === menuName); - - if (navItem && navItem.submenu) { - const isMatchingSubmenu = navItem.submenu.some(subItem => - currentPath === subItem.path || currentPath.startsWith(subItem.path + "/") - ); - - if (!isMatchingSubmenu) { - newState[menuName] = false; - } - } - } - }); - - return newState; - }); - }, [location.pathname]); - const toggleSubmenu = (name: string) => { if (!isExpanded && !isMobile) { toggleSidebar(); // Auto expand when clicking submenu in collapsed state @@ -288,7 +272,7 @@ const AppSidebar: React.FC = () => { {visibleNavItems.map((nav) => { const active = isActive(nav.path); const hasSubmenu = nav.submenu && nav.submenu.length > 0; - const isMenuOpen = openSubmenus[nav.name]; + const isMenuOpen = openSubmenus[nav.name] || isSubmenuRouteActive(nav.submenu); if (hasSubmenu) { return ( From 99c1ca4b1ebb5f467b09755914696ca995ac4d69 Mon Sep 17 00:00:00 2001 From: amee Date: Mon, 20 Apr 2026 11:08:55 +0530 Subject: [PATCH 2/2] feat: hide/show columns filter, export button hidden, pagination --- src/application/logs/LogsPage.tsx | 21 +- src/application/roles/components/AllRoles.tsx | 19 +- .../components/AllSubscriptions.tsx | 20 +- .../tenants/components/AllTenants.tsx | 21 +- src/application/theme/PaletteApi.ts | 6 +- src/application/theme/ThemeTypes.ts | 9 +- .../theme/components/AllPalettes.tsx | 16 +- .../theme/components/PaletteForm.tsx | 22 +- src/application/users/components/AllUsers.tsx | 22 +- src/components/custom/CustomTable.tsx | 359 +++++++++++++++++- src/i18n/locales/ar/common.json | 3 + src/i18n/locales/en/common.json | 3 + src/lib/tablePageSize.ts | 57 +++ 13 files changed, 532 insertions(+), 46 deletions(-) create mode 100644 src/lib/tablePageSize.ts diff --git a/src/application/logs/LogsPage.tsx b/src/application/logs/LogsPage.tsx index 73541fa..3ace440 100644 --- a/src/application/logs/LogsPage.tsx +++ b/src/application/logs/LogsPage.tsx @@ -12,6 +12,11 @@ import type { ColumnSortDirection } from "../../components/custom/CustomColumnFi import { useDebounce } from "../../components/hooks/useDebounce"; import { apiClient } from "../../lib/apiClient"; import { buildQueryString } from "../../lib/queryParams"; +import { + DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + resolveStoredTablePageSize, +} from "../../lib/tablePageSize"; interface AuditLog extends Record { id: string; @@ -37,7 +42,13 @@ const LogsPage = () => { const [allLogsForCounts, setAllLogsForCounts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(() => + resolveStoredTablePageSize({ + storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + defaultPageSize: 10, + }) + ); const [search, setSearch] = useState(""); const [moduleFilter, setModuleFilter] = useState([]); const [actionFilter, setActionFilter] = useState([]); @@ -146,6 +157,7 @@ const LogsPage = () => { () => [ { key: "module_name", + visibilityLabel: t("columns.module"), header: (
{t("columns.module")} @@ -166,6 +178,7 @@ const LogsPage = () => { }, { key: "action_type", + visibilityLabel: t("columns.action"), header: (
{t("columns.action")} @@ -203,6 +216,7 @@ const LogsPage = () => { { key: "description", header: t("columns.description") }, { key: "performed_by_email", + visibilityLabel: t("columns.performedBy"), header: (
{t("columns.performedBy")} @@ -224,6 +238,7 @@ const LogsPage = () => { { key: "ip_address", header: t("columns.ipAddress") }, { key: "created_at", + visibilityLabel: t("columns.timestamp"), header: (
{t("columns.timestamp")} @@ -270,7 +285,11 @@ const LogsPage = () => { data={logs} columns={columns} + columnVisibilityEnabled + columnVisibilityStorageKey="logs-table-columns" + pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY} isLoading={isLoading} + exportEnabled={false} exportFileName={t("exportFileName")} manualPagination manualFiltering diff --git a/src/application/roles/components/AllRoles.tsx b/src/application/roles/components/AllRoles.tsx index eb72844..8d8119b 100644 --- a/src/application/roles/components/AllRoles.tsx +++ b/src/application/roles/components/AllRoles.tsx @@ -34,6 +34,11 @@ import type { Tenant } from "../../tenants/TenantsTypes"; import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; import { useAuth } from "../../../context/AuthContext"; import { useDebounce } from "../../../components/hooks/useDebounce"; +import { + DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + resolveStoredTablePageSize, +} from "../../../lib/tablePageSize"; const AllRoles = () => { const { t, i18n } = useTranslation(['roles', 'common']); @@ -46,7 +51,13 @@ const AllRoles = () => { const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(() => + resolveStoredTablePageSize({ + storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + defaultPageSize: 10, + }) + ); const [search, setSearch] = useState(""); const [roleNameFilter, setRoleNameFilter] = useState([]); const [tenantFilter, setTenantFilter] = useState([]); @@ -340,6 +351,7 @@ const AllRoles = () => { () => [ { key: "role_name", + visibilityLabel: t('columns.roleName'), header: (
{t('columns.roleName')} @@ -359,6 +371,7 @@ const AllRoles = () => { }, { key: "tenant_id", + visibilityLabel: t('columns.tenant'), header: (
{t('columns.tenant')} @@ -475,6 +488,10 @@ const AllRoles = () => { row.id} manualPagination manualFiltering diff --git a/src/application/subscriptions/components/AllSubscriptions.tsx b/src/application/subscriptions/components/AllSubscriptions.tsx index cf436b6..5f72b29 100644 --- a/src/application/subscriptions/components/AllSubscriptions.tsx +++ b/src/application/subscriptions/components/AllSubscriptions.tsx @@ -30,6 +30,11 @@ import { subscriptionsApi } from "../SubscriptionsApi"; import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; import { useAuth } from "../../../context/AuthContext"; import { useDebounce } from "../../../components/hooks/useDebounce"; +import { + DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + resolveStoredTablePageSize, +} from "../../../lib/tablePageSize"; const formatPrice = (price?: number | null) => { if (price == null) return "-"; @@ -56,7 +61,13 @@ const AllSubscriptions = () => { const [errorMessage, setErrorMessage] = useState(""); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(() => + resolveStoredTablePageSize({ + storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + defaultPageSize: 10, + }) + ); const [search, setSearch] = useState(""); const [nameFilter, setNameFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); @@ -358,6 +369,7 @@ const AllSubscriptions = () => { () => [ { key: "name", + visibilityLabel: "Plan Name", header: (
Plan Name @@ -397,6 +409,7 @@ const AllSubscriptions = () => { }, { key: "status", + visibilityLabel: "Status", header: (
Status @@ -426,6 +439,7 @@ const AllSubscriptions = () => { }, { key: "is_public", + visibilityLabel: "Visibility", header: (
Visibility @@ -539,6 +553,10 @@ const AllSubscriptions = () => { row.id} manualPagination manualFiltering diff --git a/src/application/tenants/components/AllTenants.tsx b/src/application/tenants/components/AllTenants.tsx index 15cccb2..754d016 100644 --- a/src/application/tenants/components/AllTenants.tsx +++ b/src/application/tenants/components/AllTenants.tsx @@ -28,6 +28,11 @@ import { tenantsApi } from "../TenantsApi"; import { useAuth } from "../../../context/AuthContext"; import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi"; import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes"; +import { + DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + resolveStoredTablePageSize, +} from "../../../lib/tablePageSize"; const toIsoDate = (value: Date) => value.toISOString().slice(0, 10); @@ -104,7 +109,13 @@ const AllTenants = () => { const [isDeleting, setIsDeleting] = useState(false); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(() => + resolveStoredTablePageSize({ + storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + defaultPageSize: 10, + }) + ); const [search, setSearch] = useState(""); const [tenantNameFilter, setTenantNameFilter] = useState([]); const [tenantDomainFilter, setTenantDomainFilter] = useState([]); @@ -427,6 +438,7 @@ const AllTenants = () => { () => [ { key: "tenant_name", + visibilityLabel: "Tenant Name", header: (
Tenant Name @@ -448,6 +460,7 @@ const AllTenants = () => { }, { key: "tenant_domain", + visibilityLabel: "Domain", header: (
Domain @@ -484,6 +497,7 @@ const AllTenants = () => { }, { key: "plan_id", + visibilityLabel: "Plan", header: (
Plan @@ -514,6 +528,7 @@ const AllTenants = () => { }, { key: "is_active", + visibilityLabel: "Status", header: (
Status @@ -650,6 +665,10 @@ const AllTenants = () => { row.id} manualPagination={canReadAll} manualFiltering={canReadAll} diff --git a/src/application/theme/PaletteApi.ts b/src/application/theme/PaletteApi.ts index 8ee48f7..9cca2a5 100644 --- a/src/application/theme/PaletteApi.ts +++ b/src/application/theme/PaletteApi.ts @@ -1,5 +1,5 @@ import { apiClient } from "../../lib/apiClient"; -import type { ColorPalette } from "./ThemeTypes"; +import type { ColorPalette, ColorPalettePayload } from "./ThemeTypes"; export const paletteApi = { getAllPalettes: async () => { @@ -10,11 +10,11 @@ export const paletteApi = { return await apiClient.get(`/api/theme/get/${id}`); }, - createPalette: async (paletteData: any) => { + createPalette: async (paletteData: ColorPalettePayload) => { return await apiClient.post("/api/theme/create", paletteData); }, - updatePalette: async (id: string, paletteData: any) => { + updatePalette: async (id: string, paletteData: ColorPalettePayload) => { return await apiClient.put(`/api/theme/update/${id}`, paletteData); }, diff --git a/src/application/theme/ThemeTypes.ts b/src/application/theme/ThemeTypes.ts index 133c7bc..df2782a 100644 --- a/src/application/theme/ThemeTypes.ts +++ b/src/application/theme/ThemeTypes.ts @@ -42,5 +42,12 @@ export interface ColorPalette { tenant_id?: string; created_at?: string; updated_at?: string; - [key: string]: any; // Allow for other properties and CustomTable compatibility + [key: string]: unknown; // Allow for other properties and CustomTable compatibility +} + +export interface ColorPalettePayload { + name: string; + description?: string; + is_default: boolean; + colors: ColorSet; } diff --git a/src/application/theme/components/AllPalettes.tsx b/src/application/theme/components/AllPalettes.tsx index 3ccc09f..605aeb1 100644 --- a/src/application/theme/components/AllPalettes.tsx +++ b/src/application/theme/components/AllPalettes.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useCallback, useEffect, useState, useMemo } from "react"; import { Plus, Edit2, Trash2 } from "lucide-react"; import { CustomButton } from "../../../components/custom"; import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable"; import { CustomActionMenu, CustomActionItem, CustomStatus, CustomConfirmationModal } from "../../../components/custom"; import { Loader } from "../../../components/custom/CustomLoader"; import { paletteApi } from "../PaletteApi"; -import type { ColorPalette } from "../ThemeTypes"; +import type { ColorPalette, ColorPalettePayload } from "../ThemeTypes"; import PaletteForm from "./PaletteForm"; import { useTheme } from "../../../context/ThemeContext"; import { useTranslation } from "react-i18next"; @@ -21,7 +21,7 @@ const AllPalettes: React.FC = () => { const [deleteId, setDeleteId] = useState(null); const { refreshTheme } = useTheme(); - const fetchPalettes = async () => { + const fetchPalettes = useCallback(async () => { setIsLoading(true); setErrorMessage(""); try { @@ -34,11 +34,11 @@ const AllPalettes: React.FC = () => { } finally { setIsLoading(false); } - }; + }, [t]); useEffect(() => { fetchPalettes(); - }, []); + }, [fetchPalettes]); const handleCreate = () => { setEditingPalette(null); @@ -69,7 +69,7 @@ const AllPalettes: React.FC = () => { } }; - const handleSubmit = async (data: any) => { + const handleSubmit = async (data: ColorPalettePayload) => { setIsLoading(true); try { if (editingPalette) { @@ -192,6 +192,9 @@ const AllPalettes: React.FC = () => { row.name} @@ -222,4 +225,3 @@ const AllPalettes: React.FC = () => { }; export default AllPalettes; - diff --git a/src/application/theme/components/PaletteForm.tsx b/src/application/theme/components/PaletteForm.tsx index bb8a0e4..7b01a4d 100644 --- a/src/application/theme/components/PaletteForm.tsx +++ b/src/application/theme/components/PaletteForm.tsx @@ -5,11 +5,11 @@ import { useTranslation } from "react-i18next"; import CustomModal from "../../../components/custom/CustomModal"; import CustomInput from "../../../components/custom/CustomInput"; import {CustomButton} from "../../../components/custom"; -import type { ColorPalette } from "../ThemeTypes"; +import type { ColorPalette, ColorPalettePayload, ColorSet } from "../ThemeTypes"; interface PaletteFormProps { initialData?: ColorPalette | null; - onSubmit: (data: any) => Promise; + onSubmit: (data: ColorPalettePayload) => Promise; onCancel: () => void; isLoading?: boolean; isOpen: boolean; @@ -37,12 +37,12 @@ const colorFields = [ { key: "table_header_bg" }, { key: "table_row_hover" }, { key: "table_border" }, -]; + ] as const satisfies Array<{ key: keyof ColorSet }>; const emptyColors = colorFields.reduce((acc, field) => { acc[field.key] = "#000000"; return acc; -}, {} as Record); +}, {} as ColorSet); const PaletteForm: React.FC = ({ initialData, @@ -59,7 +59,7 @@ const PaletteForm: React.FC = ({ watch, reset, formState: { errors }, - } = useForm({ + } = useForm({ defaultValues: { name: "", description: "", @@ -68,6 +68,8 @@ const PaletteForm: React.FC = ({ }, }); + const watchedColors = watch("colors"); + useEffect(() => { if (initialData) { reset({ @@ -86,7 +88,7 @@ const PaletteForm: React.FC = ({ } }, [initialData, isOpen, reset]); - const handleFormSubmit = async (data: any) => { + const handleFormSubmit = async (data: ColorPalettePayload) => { await onSubmit(data); }; @@ -159,7 +161,7 @@ const PaletteForm: React.FC = ({
{colorFields.map((field) => { - const currentColor = watch(`colors.${field.key}` as any); + const currentColor = watchedColors[field.key]; return (
@@ -169,7 +171,7 @@ const PaletteForm: React.FC = ({ value={currentColor} onChange={(e) => { setValue( - `colors.${field.key}` as any, + `colors.${field.key}`, e.target.value, { shouldDirty: true } ); @@ -178,7 +180,7 @@ const PaletteForm: React.FC = ({ = ({ ); }; -export default PaletteForm; \ No newline at end of file +export default PaletteForm; diff --git a/src/application/users/components/AllUsers.tsx b/src/application/users/components/AllUsers.tsx index 1f462bc..1fd191c 100644 --- a/src/application/users/components/AllUsers.tsx +++ b/src/application/users/components/AllUsers.tsx @@ -33,6 +33,11 @@ import { tenantsApi } from "../../tenants/TenantsApi"; import type { Tenant } from "../../tenants/TenantsTypes"; import { useDebounce } from "../../../components/hooks/useDebounce"; import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; +import { + DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + resolveStoredTablePageSize, +} from "../../../lib/tablePageSize"; const formatName = (firstName: string, lastName?: string | null) => [firstName, lastName].filter(Boolean).join(" "); @@ -71,7 +76,13 @@ const AllUsers = () => { // Pagination & filters const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(() => + resolveStoredTablePageSize({ + storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY, + pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS, + defaultPageSize: 10, + }) + ); const [search, setSearch] = useState(""); const [nameFilter, setNameFilter] = useState([]); const [emailFilter, setEmailFilter] = useState([]); @@ -422,6 +433,7 @@ const AllUsers = () => { () => [ { key: "first_name", + visibilityLabel: t('columns.name'), header: (
{t('columns.name')} @@ -442,6 +454,7 @@ const AllUsers = () => { }, { key: "email", + visibilityLabel: t('columns.email'), header: (
{t('columns.email')} @@ -466,6 +479,7 @@ const AllUsers = () => { }, { key: "status", + visibilityLabel: t('columns.status'), header: (
{t('columns.status')} @@ -491,6 +505,7 @@ const AllUsers = () => { }, { key: "tenant_id", + visibilityLabel: t('columns.tenant'), header: (
{t('columns.tenant')} @@ -516,6 +531,7 @@ const AllUsers = () => { }, { key: "role_id", + visibilityLabel: t('columns.role'), header: (
{t('columns.role')} @@ -595,6 +611,10 @@ const AllUsers = () => { row.id} manualPagination manualFiltering diff --git a/src/components/custom/CustomTable.tsx b/src/components/custom/CustomTable.tsx index 52480dc..49b126d 100644 --- a/src/components/custom/CustomTable.tsx +++ b/src/components/custom/CustomTable.tsx @@ -1,10 +1,15 @@ import * as React from "react"; +import { createPortal } from "react-dom"; import CustomButton from "./CustomButton"; import CustomInput from "./CustomInput"; -import { Upload, ChevronLeft, ChevronRight } from "lucide-react"; +import { Upload, ChevronLeft, ChevronRight, Columns3, Check } from "lucide-react"; import { useTranslation } from "react-i18next"; +import { + persistTablePageSize, + resolveStoredTablePageSize, +} from "../../lib/tablePageSize"; -export function cn(...parts: Array) { +function cn(...parts: Array) { return parts.filter(Boolean).join(" "); } @@ -18,8 +23,10 @@ export type Primitive = | Record; export type ColumnDef = { + id?: string; key: keyof T | string; header: React.ReactNode; + visibilityLabel?: string; exportHeader?: string; render?: (row: T) => React.ReactNode; searchable?: boolean; @@ -31,10 +38,14 @@ export type DataTableProps> = { columns: Array>; defaultPageSize?: number; pageSizeOptions?: number[]; + exportEnabled?: boolean; exportFileName?: string; className?: string; getRowId?: (row: T, index: number) => string | number; filterControls?: React.ReactNode; + columnVisibilityEnabled?: boolean; + columnVisibilityStorageKey?: string; + pageSizeStorageKey?: string; enableSearchDropdown?: boolean; buildSuggestionLabel?: (row: T) => string; @@ -79,6 +90,19 @@ function downloadCSV(fileName: string, rows: string[]) { URL.revokeObjectURL(url); } +function getColumnId(column: ColumnDef) { + return column.id ?? String(column.key); +} + +function getColumnVisibilityLabel( + column: ColumnDef, + fallbackLabel: string +) { + if (column.visibilityLabel) return column.visibilityLabel; + if (typeof column.header === "string") return column.header; + return fallbackLabel; +} + export function DataTable>( props: DataTableProps ) { @@ -88,10 +112,14 @@ export function DataTable>( columns, defaultPageSize = 10, pageSizeOptions = [5, 10, 20, 50], + exportEnabled = false, exportFileName = "export.csv", className, getRowId, filterControls, + columnVisibilityEnabled = false, + columnVisibilityStorageKey, + pageSizeStorageKey, enableSearchDropdown = false, buildSuggestionLabel, onSuggestionSelect, @@ -112,22 +140,158 @@ export function DataTable>( } = props; const [internalSearch, setInternalSearch] = React.useState(""); - const [internalPageSize, setInternalPageSize] = React.useState(defaultPageSize); + const [internalPageSize, setInternalPageSize] = React.useState(() => + resolveStoredTablePageSize({ + storageKey: pageSizeStorageKey, + pageSizeOptions, + defaultPageSize, + }) + ); const [internalPage, setInternalPage] = React.useState(1); + const [visibleColumnIds, setVisibleColumnIds] = React.useState( + null + ); + const [isColumnsMenuOpen, setIsColumnsMenuOpen] = React.useState(false); + const [columnsMenuPos, setColumnsMenuPos] = React.useState<{ + top: number; + left: number; + } | null>(null); + const [draftVisibleColumnIds, setDraftVisibleColumnIds] = React.useState([]); + const columnsMenuRef = React.useRef(null); + const columnsButtonRef = React.useRef(null); const search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch; const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize; const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage; + const normalizedColumns = React.useMemo( + () => + columns.map((column, index) => ({ + ...column, + _columnId: getColumnId(column), + _fallbackLabel: `${t("actions.columns", { defaultValue: "Columns" })} ${index + 1}`, + })), + [columns, t] + ); + + React.useEffect(() => { + if (!columnVisibilityEnabled) { + setVisibleColumnIds(null); + return; + } + + const allColumnIds = normalizedColumns.map((column) => column._columnId); + + if (typeof window === "undefined" || !columnVisibilityStorageKey) { + setVisibleColumnIds((current) => current ?? allColumnIds); + return; + } + + try { + const rawValue = window.localStorage.getItem(columnVisibilityStorageKey); + if (!rawValue) { + setVisibleColumnIds(allColumnIds); + return; + } + + const parsedValue = JSON.parse(rawValue); + if (!Array.isArray(parsedValue)) { + setVisibleColumnIds(allColumnIds); + return; + } + + const sanitizedIds = parsedValue.filter( + (value): value is string => + typeof value === "string" && allColumnIds.includes(value) + ); + + setVisibleColumnIds(sanitizedIds.length > 0 ? sanitizedIds : allColumnIds); + } catch { + setVisibleColumnIds(allColumnIds); + } + }, [columnVisibilityEnabled, columnVisibilityStorageKey, normalizedColumns]); + + React.useEffect(() => { + if (!columnVisibilityEnabled || !columnVisibilityStorageKey || !visibleColumnIds) { + return; + } + + if (typeof window === "undefined") return; + + window.localStorage.setItem( + columnVisibilityStorageKey, + JSON.stringify(visibleColumnIds) + ); + }, [columnVisibilityEnabled, columnVisibilityStorageKey, visibleColumnIds]); + + React.useEffect(() => { + if (!columnVisibilityEnabled || !isColumnsMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if ( + columnsMenuRef.current?.contains(target) || + columnsButtonRef.current?.contains(target) + ) { + return; + } + setIsColumnsMenuOpen(false); + setColumnsMenuPos(null); + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [columnVisibilityEnabled, isColumnsMenuOpen]); + + const resolvedVisibleColumnIds = React.useMemo(() => { + if (!columnVisibilityEnabled) { + return normalizedColumns.map((column) => column._columnId); + } + + const allColumnIds = normalizedColumns.map((column) => column._columnId); + const safeVisibleIds = + visibleColumnIds?.filter((id) => allColumnIds.includes(id)) ?? allColumnIds; + + return safeVisibleIds.length > 0 ? safeVisibleIds : allColumnIds; + }, [columnVisibilityEnabled, normalizedColumns, visibleColumnIds]); + + const visibleColumns = React.useMemo( + () => + normalizedColumns.filter((column) => + resolvedVisibleColumnIds.includes(column._columnId) + ), + [normalizedColumns, resolvedVisibleColumnIds] + ); + React.useEffect(() => { if (!manualPagination) { setInternalPage(1); } }, [internalSearch, internalPageSize, manualPagination]); + React.useEffect(() => { + if (manualPagination || controlledPageSize !== undefined) { + return; + } + + setInternalPageSize( + resolveStoredTablePageSize({ + storageKey: pageSizeStorageKey, + pageSizeOptions, + defaultPageSize, + }) + ); + }, [ + controlledPageSize, + defaultPageSize, + manualPagination, + pageSizeOptions, + pageSizeStorageKey, + ]); + const searchableColumns = React.useMemo( - () => columns.filter((c) => c.searchable !== false), - [columns] + () => visibleColumns.filter((c) => c.searchable !== false), + [visibleColumns] ); const getCellValue = React.useCallback( @@ -162,6 +326,7 @@ export function DataTable>( const exportCurrentView = () => { const header = columns + .filter((c) => resolvedVisibleColumnIds.includes(getColumnId(c))) .map((c) => toCSVValue( c.exportHeader ?? @@ -170,7 +335,7 @@ export function DataTable>( ) .join(","); const lines = pageRows.map((row) => - columns + visibleColumns .map((c) => { const value = c.render ? c.render(row) : getCellValue(row, c.key); if (typeof value === "string" || typeof value === "number") @@ -282,6 +447,74 @@ export function DataTable>( } }; + const toggleColumnVisibility = (columnId: string) => { + if (!columnVisibilityEnabled) return; + + setDraftVisibleColumnIds((current) => { + const allColumnIds = normalizedColumns.map((column) => column._columnId); + const currentIds = current.length > 0 ? current : allColumnIds; + const isVisible = currentIds.includes(columnId); + + if (isVisible) { + if (currentIds.length === 1) { + return currentIds; + } + return currentIds.filter((id) => id !== columnId); + } + + return allColumnIds.filter( + (id) => id === columnId || currentIds.includes(id) + ); + }); + }; + + const openColumnsMenu = () => { + if (!columnVisibilityEnabled || !columnsButtonRef.current) return; + + const rect = columnsButtonRef.current.getBoundingClientRect(); + const popoverWidth = Math.min(420, window.innerWidth - 32); + const preferredLeft = rect.right - popoverWidth; + const clampedLeft = Math.min( + Math.max(16, preferredLeft), + Math.max(16, window.innerWidth - popoverWidth - 16) + ); + + setColumnsMenuPos({ top: rect.bottom + 8, left: clampedLeft }); + setDraftVisibleColumnIds(resolvedVisibleColumnIds); + setIsColumnsMenuOpen(true); + }; + + const closeColumnsMenu = () => { + setIsColumnsMenuOpen(false); + setColumnsMenuPos(null); + }; + + const handleColumnsMenuToggle = () => { + if (isColumnsMenuOpen) { + closeColumnsMenu(); + return; + } + + openColumnsMenu(); + }; + + const handleClearColumns = () => { + const allColumnIds = normalizedColumns.map((column) => column._columnId); + setDraftVisibleColumnIds(allColumnIds); + setVisibleColumnIds(allColumnIds); + closeColumnsMenu(); + }; + + const handleApplyColumns = () => { + const nextVisibleIds = + draftVisibleColumnIds.length > 0 + ? draftVisibleColumnIds + : normalizedColumns.map((column) => column._columnId); + + setVisibleColumnIds(nextVisibleIds); + closeColumnsMenu(); + }; + return (
>( )}
-
+
{filterControls} - } - > - {t('actions.export')} - + {columnVisibilityEnabled ? ( +
+ } + > + {t("actions.columns", { defaultValue: "Columns" })} + +
+ ) : null} + {exportEnabled ? ( + } + > + {t('actions.export')} + + ) : null}
+ {isColumnsMenuOpen && columnsMenuPos + ? createPortal( +
+
+

+ {t("actions.columns", { defaultValue: "Columns" })} +

+
+ +
+ {normalizedColumns.map((column) => { + const columnId = column._columnId; + const isChecked = draftVisibleColumnIds.includes(columnId); + const isLastVisible = + isChecked && draftVisibleColumnIds.length === 1; + + return ( + + ); + })} +
+ +
+ + + {t("actions.apply", { defaultValue: "Apply" })} + +
+
, + document.body + ) + : null} +
>( - {columns.map((c, idx) => ( + {visibleColumns.map((c, idx) => (
>( {isLoading ? (
@@ -405,7 +723,7 @@ export function DataTable>( isHighlighted && highlightClassName )} > - {columns.map((c, ci) => { + {visibleColumns.map((c, ci) => { const content = c.render ? c.render(row) : (getCellValue(row, c.key) as React.ReactNode); @@ -427,7 +745,7 @@ export function DataTable>( ) : (
{t('common.noData')} @@ -452,6 +770,7 @@ export function DataTable>( value={pageSize} onChange={(e) => { const newSize = Number(e.target.value); + persistTablePageSize(pageSizeStorageKey, newSize, pageSizeOptions); if (manualPagination && onPageSizeChange) { onPageSizeChange(newSize); } else { @@ -507,4 +826,4 @@ export function DataTable>( ); } -export default DataTable; \ No newline at end of file +export default DataTable; diff --git a/src/i18n/locales/ar/common.json b/src/i18n/locales/ar/common.json index bbf1607..36ae14a 100644 --- a/src/i18n/locales/ar/common.json +++ b/src/i18n/locales/ar/common.json @@ -28,13 +28,16 @@ "export": "تصدير", "import": "استيراد", "reset": "إعادة تعيين", + "clear": "مسح", "submit": "إرسال", + "apply": "تطبيق", "close": "إغلاق", "confirm": "تأكيد", "back": "رجوع", "next": "التالي", "previous": "السابق", "view": "عرض", + "columns": "الأعمدة", "refresh": "تحديث", "signout": "تسجيل الخروج", "logout": "تسجيل الخروج" diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index f49e08d..5810d9a 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -28,13 +28,16 @@ "export": "Export", "import": "Import", "reset": "Reset", + "clear": "Clear", "submit": "Submit", + "apply": "Apply", "close": "Close", "confirm": "Confirm", "back": "Back", "next": "Next", "previous": "Previous", "view": "View", + "columns": "Columns", "refresh": "Refresh", "signout": "Sign Out", "logout": "Logout" diff --git a/src/lib/tablePageSize.ts b/src/lib/tablePageSize.ts new file mode 100644 index 0000000..cea07b8 --- /dev/null +++ b/src/lib/tablePageSize.ts @@ -0,0 +1,57 @@ +export const DEFAULT_TABLE_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; + +export const SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY = + "admin-table-page-size"; + +type ResolveTablePageSizeArgs = { + storageKey?: string; + pageSizeOptions?: readonly number[]; + defaultPageSize: number; +}; + +function isValidPageSize(pageSize: number, pageSizeOptions?: readonly number[]) { + if (!Number.isFinite(pageSize) || pageSize <= 0) return false; + if (!pageSizeOptions || pageSizeOptions.length === 0) return true; + return pageSizeOptions.includes(pageSize); +} + +export function resolveStoredTablePageSize({ + storageKey, + pageSizeOptions, + defaultPageSize, +}: ResolveTablePageSizeArgs) { + if (!isValidPageSize(defaultPageSize, pageSizeOptions)) { + return pageSizeOptions?.[0] ?? 10; + } + + if (typeof window === "undefined" || !storageKey) { + return defaultPageSize; + } + + try { + const rawValue = window.localStorage.getItem(storageKey); + if (!rawValue) return defaultPageSize; + + const parsedValue = Number(rawValue); + return isValidPageSize(parsedValue, pageSizeOptions) + ? parsedValue + : defaultPageSize; + } catch { + return defaultPageSize; + } +} + +export function persistTablePageSize( + storageKey: string | undefined, + pageSize: number, + pageSizeOptions?: readonly number[] +) { + if (typeof window === "undefined" || !storageKey) return; + if (!isValidPageSize(pageSize, pageSizeOptions)) return; + + try { + window.localStorage.setItem(storageKey, String(pageSize)); + } catch { + // Ignore storage failures so pagination keeps working normally. + } +}