Merge pull request 'ameenah' (#23) from ameenah into dev
Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/23
This commit is contained in:
@@ -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<string, unknown> {
|
||||
id: string;
|
||||
@@ -37,7 +42,13 @@ const LogsPage = () => {
|
||||
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
|
||||
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<string[]>([]);
|
||||
const [actionFilter, setActionFilter] = useState<string[]>([]);
|
||||
@@ -146,6 +157,7 @@ const LogsPage = () => {
|
||||
() => [
|
||||
{
|
||||
key: "module_name",
|
||||
visibilityLabel: t("columns.module"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.module")}
|
||||
@@ -166,6 +178,7 @@ const LogsPage = () => {
|
||||
},
|
||||
{
|
||||
key: "action_type",
|
||||
visibilityLabel: t("columns.action"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.action")}
|
||||
@@ -203,6 +216,7 @@ const LogsPage = () => {
|
||||
{ key: "description", header: t("columns.description") },
|
||||
{
|
||||
key: "performed_by_email",
|
||||
visibilityLabel: t("columns.performedBy"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.performedBy")}
|
||||
@@ -224,6 +238,7 @@ const LogsPage = () => {
|
||||
{ key: "ip_address", header: t("columns.ipAddress") },
|
||||
{
|
||||
key: "created_at",
|
||||
visibilityLabel: t("columns.timestamp"),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t("columns.timestamp")}
|
||||
@@ -270,7 +285,11 @@ const LogsPage = () => {
|
||||
<DataTable<AuditLog>
|
||||
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
|
||||
|
||||
@@ -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<Module[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
||||
@@ -340,6 +351,7 @@ const AllRoles = () => {
|
||||
() => [
|
||||
{
|
||||
key: "role_name",
|
||||
visibilityLabel: t('columns.roleName'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.roleName')}
|
||||
@@ -359,6 +371,7 @@ const AllRoles = () => {
|
||||
},
|
||||
{
|
||||
key: "tenant_id",
|
||||
visibilityLabel: t('columns.tenant'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.tenant')}
|
||||
@@ -475,6 +488,10 @@ const AllRoles = () => {
|
||||
<CustomTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="roles-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 = () => {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Duration (Days)"
|
||||
name="duration_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
value={formData.duration_days ?? ""}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
duration_days: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
|
||||
@@ -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 "-";
|
||||
@@ -43,6 +48,7 @@ interface EditFormState {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number | undefined;
|
||||
duration_days: number | undefined;
|
||||
is_public: boolean;
|
||||
status: string;
|
||||
}
|
||||
@@ -55,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<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
@@ -88,6 +100,7 @@ const AllSubscriptions = () => {
|
||||
name: "",
|
||||
description: "",
|
||||
price: undefined,
|
||||
duration_days: undefined,
|
||||
is_public: true,
|
||||
status: "active",
|
||||
});
|
||||
@@ -249,6 +262,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 +325,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,
|
||||
@@ -354,6 +369,7 @@ const AllSubscriptions = () => {
|
||||
() => [
|
||||
{
|
||||
key: "name",
|
||||
visibilityLabel: "Plan Name",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Plan Name
|
||||
@@ -381,8 +397,19 @@ const AllSubscriptions = () => {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "duration_days",
|
||||
header: "Duration",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{row.duration_days ? `${row.duration_days} days` : "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
visibilityLabel: "Status",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
@@ -412,6 +439,7 @@ const AllSubscriptions = () => {
|
||||
},
|
||||
{
|
||||
key: "is_public",
|
||||
visibilityLabel: "Visibility",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Visibility
|
||||
@@ -525,6 +553,10 @@ const AllSubscriptions = () => {
|
||||
<CustomTable
|
||||
data={plans}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="subscriptions-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
@@ -570,6 +602,14 @@ const AllSubscriptions = () => {
|
||||
{formatPrice(selectedPlan.price)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Duration
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedPlan.duration_days ? `${selectedPlan.duration_days} days` : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
@@ -689,6 +729,19 @@ const AllSubscriptions = () => {
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<CustomInput
|
||||
label="Duration (Days)"
|
||||
name="duration_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
value={editForm.duration_days ?? ""}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
duration_days: e.target.value ? Number(e.target.value) : undefined,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
export type TenantStatus = "ACTIVE" | "INACTIVE" | "EXPIRED";
|
||||
|
||||
export type Tenant = {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active: boolean;
|
||||
plan_id?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
status: TenantStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -19,6 +25,9 @@ export type TenantCreateRequest = {
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string;
|
||||
plan_id: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: TenantStatus;
|
||||
module_environments?: ModuleEnvironmentAssignment[];
|
||||
};
|
||||
|
||||
@@ -28,6 +37,9 @@ export type TenantUpdateRequest = {
|
||||
tenant_logo_url?: string | null;
|
||||
is_active?: boolean;
|
||||
plan_id?: string;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
status?: TenantStatus;
|
||||
};
|
||||
|
||||
export type TenantPaginatedResponse = {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CustomButton, CustomInput, CustomDropdown } from "../../../components/custom";
|
||||
import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../../../components/custom";
|
||||
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment } from "../TenantsTypes";
|
||||
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
||||
@@ -13,7 +13,17 @@ import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
|
||||
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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<TenantStatus>("ACTIVE");
|
||||
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
|
||||
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
|
||||
<CustomDatePicker
|
||||
label="Start Date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
const nextStartDate = e.target.value;
|
||||
setStartDate(nextStartDate);
|
||||
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
|
||||
setEndDate(addDays(nextStartDate, selectedPlan?.duration_days));
|
||||
}}
|
||||
/>
|
||||
<CustomDatePicker
|
||||
label="End Date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
min={startDate || undefined}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={tenantStatus}
|
||||
onChange={(e) => setTenantStatus(e.target.value as TenantStatus)}
|
||||
options={[
|
||||
{ label: "Active", value: "ACTIVE" },
|
||||
{ label: "Inactive", value: "INACTIVE" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Module Environment Assignment — appears after plan selection */}
|
||||
{selectedPlanId && (
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CustomCheckBox,
|
||||
CustomColumnFilter,
|
||||
CustomConfirmationModal,
|
||||
CustomDatePicker,
|
||||
CustomDropdown,
|
||||
CustomInput,
|
||||
CustomModal,
|
||||
@@ -22,11 +23,25 @@ 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";
|
||||
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);
|
||||
|
||||
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<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
@@ -58,6 +73,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 +95,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<SubscriptionPlan[]>([]);
|
||||
const [isPlansLoading, setIsPlansLoading] = useState(false);
|
||||
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
@@ -89,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<string[]>([]);
|
||||
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
|
||||
@@ -224,14 +250,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 +321,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 +381,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);
|
||||
@@ -409,6 +438,7 @@ const AllTenants = () => {
|
||||
() => [
|
||||
{
|
||||
key: "tenant_name",
|
||||
visibilityLabel: "Tenant Name",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Tenant Name
|
||||
@@ -430,6 +460,7 @@ const AllTenants = () => {
|
||||
},
|
||||
{
|
||||
key: "tenant_domain",
|
||||
visibilityLabel: "Domain",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Domain
|
||||
@@ -466,6 +497,7 @@ const AllTenants = () => {
|
||||
},
|
||||
{
|
||||
key: "plan_id",
|
||||
visibilityLabel: "Plan",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Plan
|
||||
@@ -496,6 +528,7 @@ const AllTenants = () => {
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
visibilityLabel: "Status",
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
@@ -520,7 +553,27 @@ const AllTenants = () => {
|
||||
),
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
|
||||
<CustomStatus status={row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
header: "Start Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.start_date ? formatDate(row.start_date) : "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "end_date",
|
||||
header: "End Date",
|
||||
searchable: false,
|
||||
render: (row) => (
|
||||
<div className="text-sm font-medium text-(--text-primary)">
|
||||
{row.end_date ? formatDate(row.end_date) : "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -612,6 +665,10 @@ const AllTenants = () => {
|
||||
<CustomTable
|
||||
data={tenants}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="tenants-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination={canReadAll}
|
||||
manualFiltering={canReadAll}
|
||||
@@ -669,13 +726,19 @@ const AllTenants = () => {
|
||||
<p className="text-sm text-[var(--text-secondary)]"></p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Tenant ID
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] break-all">
|
||||
{selectedTenant.tenant_id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Status
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.is_active ? "Active" : "Inactive"}
|
||||
</p>
|
||||
<CustomStatus status={selectedTenant.status} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
@@ -685,6 +748,22 @@ const AllTenants = () => {
|
||||
{getPlanName(selectedTenant.plan_id)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Start Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.start_date ? formatDate(selectedTenant.start_date) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
End Date
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{selectedTenant.end_date ? formatDate(selectedTenant.end_date) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Created
|
||||
@@ -780,15 +859,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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
|
||||
<CustomDatePicker
|
||||
label="Start Date"
|
||||
value={editForm.start_date}
|
||||
onChange={(e) => {
|
||||
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),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<CustomDatePicker
|
||||
label="End Date"
|
||||
value={editForm.end_date}
|
||||
min={editForm.start_date || undefined}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({ ...prev, end_date: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={editForm.status}
|
||||
onChange={(e) =>
|
||||
setEditForm((prev) => ({
|
||||
...prev,
|
||||
status: e.target.value as TenantStatus,
|
||||
}))
|
||||
}
|
||||
options={[
|
||||
{ label: "Active", value: "ACTIVE" },
|
||||
{ label: "Inactive", value: "INACTIVE" },
|
||||
{ label: "Expired", value: "EXPIRED" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<ColorPalette>(`/api/theme/get/${id}`);
|
||||
},
|
||||
|
||||
createPalette: async (paletteData: any) => {
|
||||
createPalette: async (paletteData: ColorPalettePayload) => {
|
||||
return await apiClient.post<ColorPalette>("/api/theme/create", paletteData);
|
||||
},
|
||||
|
||||
updatePalette: async (id: string, paletteData: any) => {
|
||||
updatePalette: async (id: string, paletteData: ColorPalettePayload) => {
|
||||
return await apiClient.put<ColorPalette>(`/api/theme/update/${id}`, paletteData);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string | null>(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 = () => {
|
||||
<DataTable
|
||||
data={palettes}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="palettes-table-columns"
|
||||
exportEnabled={true}
|
||||
enableSearchDropdown={true}
|
||||
search="name"
|
||||
buildSuggestionLabel={(row) => row.name}
|
||||
@@ -222,4 +225,3 @@ const AllPalettes: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AllPalettes;
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
onSubmit: (data: ColorPalettePayload) => Promise<void>;
|
||||
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<string, string>);
|
||||
}, {} as ColorSet);
|
||||
|
||||
const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
initialData,
|
||||
@@ -59,7 +59,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
} = useForm<ColorPalettePayload>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -68,6 +68,8 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const watchedColors = watch("colors");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
reset({
|
||||
@@ -86,7 +88,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
}
|
||||
}, [initialData, isOpen, reset]);
|
||||
|
||||
const handleFormSubmit = async (data: any) => {
|
||||
const handleFormSubmit = async (data: ColorPalettePayload) => {
|
||||
await onSubmit(data);
|
||||
};
|
||||
|
||||
@@ -159,7 +161,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{colorFields.map((field) => {
|
||||
const currentColor = watch(`colors.${field.key}` as any);
|
||||
const currentColor = watchedColors[field.key];
|
||||
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2">
|
||||
@@ -169,7 +171,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
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<PaletteFormProps> = ({
|
||||
|
||||
<CustomInput
|
||||
label={t(`theme:colors.${field.key}`)}
|
||||
{...register(`colors.${field.key}` as any, {
|
||||
{...register(`colors.${field.key}`, {
|
||||
required: t('theme:form.fields.required'),
|
||||
})}
|
||||
placeholder="#000000"
|
||||
@@ -194,4 +196,4 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PaletteForm;
|
||||
export default PaletteForm;
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
||||
@@ -422,6 +433,7 @@ const AllUsers = () => {
|
||||
() => [
|
||||
{
|
||||
key: "first_name",
|
||||
visibilityLabel: t('columns.name'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.name')}
|
||||
@@ -442,6 +454,7 @@ const AllUsers = () => {
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
visibilityLabel: t('columns.email'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.email')}
|
||||
@@ -466,6 +479,7 @@ const AllUsers = () => {
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
visibilityLabel: t('columns.status'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.status')}
|
||||
@@ -491,6 +505,7 @@ const AllUsers = () => {
|
||||
},
|
||||
{
|
||||
key: "tenant_id",
|
||||
visibilityLabel: t('columns.tenant'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.tenant')}
|
||||
@@ -516,6 +531,7 @@ const AllUsers = () => {
|
||||
},
|
||||
{
|
||||
key: "role_id",
|
||||
visibilityLabel: t('columns.role'),
|
||||
header: (
|
||||
<div className="flex items-center">
|
||||
{t('columns.role')}
|
||||
@@ -595,6 +611,10 @@ const AllUsers = () => {
|
||||
<CustomTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
columnVisibilityEnabled
|
||||
columnVisibilityStorageKey="users-table-columns"
|
||||
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||
exportEnabled={false}
|
||||
getRowId={(row) => row.id}
|
||||
manualPagination
|
||||
manualFiltering
|
||||
|
||||
@@ -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<string | false | null | undefined>) {
|
||||
function cn(...parts: Array<string | false | null | undefined>) {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
@@ -18,8 +23,10 @@ export type Primitive =
|
||||
| Record<string, unknown>;
|
||||
|
||||
export type ColumnDef<T> = {
|
||||
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<T extends Record<string, unknown>> = {
|
||||
columns: Array<ColumnDef<T>>;
|
||||
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<T>(column: ColumnDef<T>) {
|
||||
return column.id ?? String(column.key);
|
||||
}
|
||||
|
||||
function getColumnVisibilityLabel<T>(
|
||||
column: ColumnDef<T>,
|
||||
fallbackLabel: string
|
||||
) {
|
||||
if (column.visibilityLabel) return column.visibilityLabel;
|
||||
if (typeof column.header === "string") return column.header;
|
||||
return fallbackLabel;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T>
|
||||
) {
|
||||
@@ -88,10 +112,14 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
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<T extends Record<string, unknown>>(
|
||||
} = 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<string[] | null>(
|
||||
null
|
||||
);
|
||||
const [isColumnsMenuOpen, setIsColumnsMenuOpen] = React.useState(false);
|
||||
const [columnsMenuPos, setColumnsMenuPos] = React.useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
const [draftVisibleColumnIds, setDraftVisibleColumnIds] = React.useState<string[]>([]);
|
||||
const columnsMenuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const columnsButtonRef = React.useRef<HTMLDivElement | null>(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<T extends Record<string, unknown>>(
|
||||
|
||||
const exportCurrentView = () => {
|
||||
const header = columns
|
||||
.filter((c) => resolvedVisibleColumnIds.includes(getColumnId(c)))
|
||||
.map((c) =>
|
||||
toCSVValue(
|
||||
c.exportHeader ??
|
||||
@@ -170,7 +335,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
)
|
||||
.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<T extends Record<string, unknown>>(
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
@@ -343,19 +576,104 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex items-center gap-2">
|
||||
{filterControls}
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={exportCurrentView}
|
||||
leftIcon={<Upload size={16} />}
|
||||
>
|
||||
{t('actions.export')}
|
||||
</CustomButton>
|
||||
{columnVisibilityEnabled ? (
|
||||
<div className="relative" ref={columnsButtonRef}>
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={handleColumnsMenuToggle}
|
||||
leftIcon={<Columns3 size={16} />}
|
||||
>
|
||||
{t("actions.columns", { defaultValue: "Columns" })}
|
||||
</CustomButton>
|
||||
</div>
|
||||
) : null}
|
||||
{exportEnabled ? (
|
||||
<CustomButton
|
||||
variant="outlined"
|
||||
size="sm"
|
||||
onClick={exportCurrentView}
|
||||
leftIcon={<Upload size={16} />}
|
||||
>
|
||||
{t('actions.export')}
|
||||
</CustomButton>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isColumnsMenuOpen && columnsMenuPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={columnsMenuRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: columnsMenuPos.top,
|
||||
left: columnsMenuPos.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 px-4 py-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{t("actions.columns", { defaultValue: "Columns" })}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[260px] overflow-y-auto py-2">
|
||||
{normalizedColumns.map((column) => {
|
||||
const columnId = column._columnId;
|
||||
const isChecked = draftVisibleColumnIds.includes(columnId);
|
||||
const isLastVisible =
|
||||
isChecked && draftVisibleColumnIds.length === 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={columnId}
|
||||
type="button"
|
||||
onClick={() => toggleColumnVisibility(columnId)}
|
||||
disabled={isLastVisible}
|
||||
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 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-transparent"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded border transition-colors ${
|
||||
isChecked
|
||||
? "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 text-slate-900">
|
||||
{getColumnVisibilityLabel(column, column._fallbackLabel)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearColumns}
|
||||
className="text-sm font-medium text-slate-500 transition hover:text-slate-700"
|
||||
>
|
||||
{t("actions.clear", { defaultValue: "Clear" })}
|
||||
</button>
|
||||
<CustomButton
|
||||
variant="primary"
|
||||
onClick={handleApplyColumns}
|
||||
className="!h-9 !rounded-xl !px-4 !text-sm"
|
||||
>
|
||||
{t("actions.apply", { defaultValue: "Apply" })}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
|
||||
<div
|
||||
className="max-w-full overflow-x-auto overflow-y-auto relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-[#1D2A4B] [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-[#1D2A4B]/80 [&::-webkit-scrollbar:vertical]:hidden"
|
||||
style={{ maxHeight }}
|
||||
@@ -363,7 +681,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
<table className="min-w-max w-full text-sm">
|
||||
<thead className="bg-[var(--table-header-bg)] sticky top-0 z-10 shadow-sm">
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-[var(--text-secondary)]">
|
||||
{columns.map((c, idx) => (
|
||||
{visibleColumns.map((c, idx) => (
|
||||
<th
|
||||
key={String(c.key) + idx}
|
||||
className={cn(
|
||||
@@ -380,7 +698,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
colSpan={visibleColumns.length}
|
||||
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
@@ -405,7 +723,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
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 extends Record<string, unknown>>(
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
colSpan={visibleColumns.length}
|
||||
className="px-6 py-12 text-center text-[var(--text-secondary)]"
|
||||
>
|
||||
{t('common.noData')}
|
||||
@@ -452,6 +770,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
||||
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<T extends Record<string, unknown>>(
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
export default DataTable;
|
||||
|
||||
@@ -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<Record<string, boolean>>({});
|
||||
|
||||
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 (
|
||||
|
||||
@@ -28,13 +28,16 @@
|
||||
"export": "تصدير",
|
||||
"import": "استيراد",
|
||||
"reset": "إعادة تعيين",
|
||||
"clear": "مسح",
|
||||
"submit": "إرسال",
|
||||
"apply": "تطبيق",
|
||||
"close": "إغلاق",
|
||||
"confirm": "تأكيد",
|
||||
"back": "رجوع",
|
||||
"next": "التالي",
|
||||
"previous": "السابق",
|
||||
"view": "عرض",
|
||||
"columns": "الأعمدة",
|
||||
"refresh": "تحديث",
|
||||
"signout": "تسجيل الخروج",
|
||||
"logout": "تسجيل الخروج"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user