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 { useDebounce } from "../../components/hooks/useDebounce";
|
||||||
import { apiClient } from "../../lib/apiClient";
|
import { apiClient } from "../../lib/apiClient";
|
||||||
import { buildQueryString } from "../../lib/queryParams";
|
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> {
|
interface AuditLog extends Record<string, unknown> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,7 +42,13 @@ const LogsPage = () => {
|
|||||||
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
|
const [allLogsForCounts, setAllLogsForCounts] = useState<AuditLog[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
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 [search, setSearch] = useState("");
|
||||||
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
|
const [moduleFilter, setModuleFilter] = useState<string[]>([]);
|
||||||
const [actionFilter, setActionFilter] = useState<string[]>([]);
|
const [actionFilter, setActionFilter] = useState<string[]>([]);
|
||||||
@@ -146,6 +157,7 @@ const LogsPage = () => {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: "module_name",
|
key: "module_name",
|
||||||
|
visibilityLabel: t("columns.module"),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t("columns.module")}
|
{t("columns.module")}
|
||||||
@@ -166,6 +178,7 @@ const LogsPage = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "action_type",
|
key: "action_type",
|
||||||
|
visibilityLabel: t("columns.action"),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t("columns.action")}
|
{t("columns.action")}
|
||||||
@@ -203,6 +216,7 @@ const LogsPage = () => {
|
|||||||
{ key: "description", header: t("columns.description") },
|
{ key: "description", header: t("columns.description") },
|
||||||
{
|
{
|
||||||
key: "performed_by_email",
|
key: "performed_by_email",
|
||||||
|
visibilityLabel: t("columns.performedBy"),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t("columns.performedBy")}
|
{t("columns.performedBy")}
|
||||||
@@ -224,6 +238,7 @@ const LogsPage = () => {
|
|||||||
{ key: "ip_address", header: t("columns.ipAddress") },
|
{ key: "ip_address", header: t("columns.ipAddress") },
|
||||||
{
|
{
|
||||||
key: "created_at",
|
key: "created_at",
|
||||||
|
visibilityLabel: t("columns.timestamp"),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t("columns.timestamp")}
|
{t("columns.timestamp")}
|
||||||
@@ -270,7 +285,11 @@ const LogsPage = () => {
|
|||||||
<DataTable<AuditLog>
|
<DataTable<AuditLog>
|
||||||
data={logs}
|
data={logs}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="logs-table-columns"
|
||||||
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
exportEnabled={false}
|
||||||
exportFileName={t("exportFileName")}
|
exportFileName={t("exportFileName")}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualFiltering
|
manualFiltering
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import CustomButton from '../../../../components/custom/CustomButton';
|
|||||||
const ModuleList = () => {
|
const ModuleList = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation(['modules', 'common']);
|
const { t } = useTranslation(['modules', 'common']);
|
||||||
const { listModules, deleteModule, updateModule, loading } = useModuleApi();
|
const { listModules, deleteModule, loading } = useModuleApi();
|
||||||
const [modules, setModules] = useState<Module[]>([]);
|
const [modules, setModules] = useState<Module[]>([]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
|
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ import type { Tenant } from "../../tenants/TenantsTypes";
|
|||||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
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 AllRoles = () => {
|
||||||
const { t, i18n } = useTranslation(['roles', 'common']);
|
const { t, i18n } = useTranslation(['roles', 'common']);
|
||||||
@@ -46,7 +51,13 @@ const AllRoles = () => {
|
|||||||
|
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
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 [search, setSearch] = useState("");
|
||||||
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
|
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
|
||||||
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
||||||
@@ -340,6 +351,7 @@ const AllRoles = () => {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: "role_name",
|
key: "role_name",
|
||||||
|
visibilityLabel: t('columns.roleName'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.roleName')}
|
{t('columns.roleName')}
|
||||||
@@ -359,6 +371,7 @@ const AllRoles = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tenant_id",
|
key: "tenant_id",
|
||||||
|
visibilityLabel: t('columns.tenant'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.tenant')}
|
{t('columns.tenant')}
|
||||||
@@ -475,6 +488,10 @@ const AllRoles = () => {
|
|||||||
<CustomTable
|
<CustomTable
|
||||||
data={roles}
|
data={roles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="roles-table-columns"
|
||||||
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||||
|
exportEnabled={false}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualFiltering
|
manualFiltering
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type SubscriptionPlan = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
|
duration_days?: number | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -18,6 +19,7 @@ export type SubscriptionPlanCreateRequest = {
|
|||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
|
duration_days?: number;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
access_ids?: string[];
|
access_ids?: string[];
|
||||||
@@ -28,6 +30,7 @@ export type SubscriptionPlanUpdateRequest = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
price?: number;
|
price?: number;
|
||||||
|
duration_days?: number;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
status?: string;
|
status?: string;
|
||||||
access_ids?: string[];
|
access_ids?: string[];
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const AddSubscriptions = () => {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
price: undefined,
|
price: undefined,
|
||||||
|
duration_days: undefined,
|
||||||
is_public: true,
|
is_public: true,
|
||||||
status: "active",
|
status: "active",
|
||||||
access_ids: [],
|
access_ids: [],
|
||||||
@@ -96,6 +97,7 @@ const AddSubscriptions = () => {
|
|||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
description: formData.description?.trim() || undefined,
|
description: formData.description?.trim() || undefined,
|
||||||
price: formData.price ? Number(formData.price) : undefined,
|
price: formData.price ? Number(formData.price) : undefined,
|
||||||
|
duration_days: formData.duration_days ? Number(formData.duration_days) : undefined,
|
||||||
is_public: formData.is_public,
|
is_public: formData.is_public,
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
access_ids: accessIds,
|
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>
|
</div>
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import { subscriptionsApi } from "../SubscriptionsApi";
|
|||||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
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) => {
|
const formatPrice = (price?: number | null) => {
|
||||||
if (price == null) return "-";
|
if (price == null) return "-";
|
||||||
@@ -43,6 +48,7 @@ interface EditFormState {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: number | undefined;
|
price: number | undefined;
|
||||||
|
duration_days: number | undefined;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
@@ -55,7 +61,13 @@ const AllSubscriptions = () => {
|
|||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
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 [search, setSearch] = useState("");
|
||||||
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||||
@@ -88,6 +100,7 @@ const AllSubscriptions = () => {
|
|||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
price: undefined,
|
price: undefined,
|
||||||
|
duration_days: undefined,
|
||||||
is_public: true,
|
is_public: true,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
@@ -249,6 +262,7 @@ const AllSubscriptions = () => {
|
|||||||
name: plan.name,
|
name: plan.name,
|
||||||
description: plan.description ?? "",
|
description: plan.description ?? "",
|
||||||
price: plan.price ?? undefined,
|
price: plan.price ?? undefined,
|
||||||
|
duration_days: plan.duration_days ?? undefined,
|
||||||
is_public: plan.is_public,
|
is_public: plan.is_public,
|
||||||
status: plan.status,
|
status: plan.status,
|
||||||
});
|
});
|
||||||
@@ -311,6 +325,7 @@ const AllSubscriptions = () => {
|
|||||||
name: editForm.name.trim(),
|
name: editForm.name.trim(),
|
||||||
description: editForm.description.trim() || undefined,
|
description: editForm.description.trim() || undefined,
|
||||||
price: editForm.price ? Number(editForm.price) : undefined,
|
price: editForm.price ? Number(editForm.price) : undefined,
|
||||||
|
duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined,
|
||||||
is_public: editForm.is_public,
|
is_public: editForm.is_public,
|
||||||
status: editForm.status,
|
status: editForm.status,
|
||||||
access_ids: accessIds,
|
access_ids: accessIds,
|
||||||
@@ -354,6 +369,7 @@ const AllSubscriptions = () => {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: "name",
|
key: "name",
|
||||||
|
visibilityLabel: "Plan Name",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Plan Name
|
Plan Name
|
||||||
@@ -381,8 +397,19 @@ const AllSubscriptions = () => {
|
|||||||
</span>
|
</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",
|
key: "status",
|
||||||
|
visibilityLabel: "Status",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Status
|
Status
|
||||||
@@ -412,6 +439,7 @@ const AllSubscriptions = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "is_public",
|
key: "is_public",
|
||||||
|
visibilityLabel: "Visibility",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Visibility
|
Visibility
|
||||||
@@ -525,6 +553,10 @@ const AllSubscriptions = () => {
|
|||||||
<CustomTable
|
<CustomTable
|
||||||
data={plans}
|
data={plans}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="subscriptions-table-columns"
|
||||||
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||||
|
exportEnabled={false}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualFiltering
|
manualFiltering
|
||||||
@@ -570,6 +602,14 @@ const AllSubscriptions = () => {
|
|||||||
{formatPrice(selectedPlan.price)}
|
{formatPrice(selectedPlan.price)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Status
|
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>
|
</div>
|
||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
export type TenantStatus = "ACTIVE" | "INACTIVE" | "EXPIRED";
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
tenant_name: string;
|
tenant_name: string;
|
||||||
tenant_domain: string;
|
tenant_domain: string;
|
||||||
tenant_logo_url?: string | null;
|
tenant_logo_url?: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
plan_id?: string | null;
|
plan_id?: string | null;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
|
status: TenantStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
@@ -19,6 +25,9 @@ export type TenantCreateRequest = {
|
|||||||
tenant_domain: string;
|
tenant_domain: string;
|
||||||
tenant_logo_url?: string;
|
tenant_logo_url?: string;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
status?: TenantStatus;
|
||||||
module_environments?: ModuleEnvironmentAssignment[];
|
module_environments?: ModuleEnvironmentAssignment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +37,9 @@ export type TenantUpdateRequest = {
|
|||||||
tenant_logo_url?: string | null;
|
tenant_logo_url?: string | null;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
plan_id?: string;
|
plan_id?: string;
|
||||||
|
start_date?: string | null;
|
||||||
|
end_date?: string | null;
|
||||||
|
status?: TenantStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantPaginatedResponse = {
|
export type TenantPaginatedResponse = {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||||
import { CustomLoader } from "../../../components/custom";
|
import { CustomLoader } from "../../../components/custom";
|
||||||
import { tenantsApi } from "../TenantsApi";
|
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 { useAuth } from "../../../context/AuthContext";
|
||||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
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 type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||||
import { AlertCircle } from "lucide-react";
|
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 AddTenants = () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||||
const canCreateTenant = hasAccess("superadmin.tenant.create");
|
const canCreateTenant = hasAccess("superadmin.tenant.create");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,6 +32,9 @@ const AddTenants = () => {
|
|||||||
const [tenantDomain, setTenantDomain] = useState("");
|
const [tenantDomain, setTenantDomain] = useState("");
|
||||||
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
|
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
|
||||||
const [selectedPlanId, setSelectedPlanId] = 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 [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
|
||||||
|
|
||||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
@@ -43,6 +56,15 @@ const AddTenants = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAuthLoading || canCreateTenant) {
|
if (isAuthLoading || canCreateTenant) {
|
||||||
setIsTenantLoading(false);
|
setIsTenantLoading(false);
|
||||||
@@ -195,6 +217,9 @@ const AddTenants = () => {
|
|||||||
tenant_domain: tenantDomain.trim(),
|
tenant_domain: tenantDomain.trim(),
|
||||||
tenant_logo_url: tenantLogoUrl.trim() || undefined,
|
tenant_logo_url: tenantLogoUrl.trim() || undefined,
|
||||||
plan_id: selectedPlanId,
|
plan_id: selectedPlanId,
|
||||||
|
start_date: startDate || undefined,
|
||||||
|
end_date: endDate || undefined,
|
||||||
|
status: tenantStatus,
|
||||||
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
|
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,6 +338,35 @@ const AddTenants = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Module Environment Assignment — appears after plan selection */}
|
||||||
{selectedPlanId && (
|
{selectedPlanId && (
|
||||||
<div className="border-t border-gray-200 pt-6">
|
<div className="border-t border-gray-200 pt-6">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
CustomCheckBox,
|
CustomCheckBox,
|
||||||
CustomColumnFilter,
|
CustomColumnFilter,
|
||||||
CustomConfirmationModal,
|
CustomConfirmationModal,
|
||||||
|
CustomDatePicker,
|
||||||
CustomDropdown,
|
CustomDropdown,
|
||||||
CustomInput,
|
CustomInput,
|
||||||
CustomModal,
|
CustomModal,
|
||||||
@@ -22,11 +23,25 @@ import {
|
|||||||
} from "../../../components/custom/CustomColumnFilter.utils";
|
} from "../../../components/custom/CustomColumnFilter.utils";
|
||||||
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
||||||
import { formatDate } from "../../../lib/dateFormat";
|
import { formatDate } from "../../../lib/dateFormat";
|
||||||
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
|
import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes";
|
||||||
import { tenantsApi } from "../TenantsApi";
|
import { tenantsApi } from "../TenantsApi";
|
||||||
import { useAuth } from "../../../context/AuthContext";
|
import { useAuth } from "../../../context/AuthContext";
|
||||||
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
|
||||||
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
|
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 {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
@@ -58,6 +73,9 @@ interface EditFormState {
|
|||||||
tenant_logo_url: string;
|
tenant_logo_url: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
plan_id: string;
|
plan_id: string;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string;
|
||||||
|
status: TenantStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllTenants = () => {
|
const AllTenants = () => {
|
||||||
@@ -77,11 +95,13 @@ const AllTenants = () => {
|
|||||||
tenant_logo_url: "",
|
tenant_logo_url: "",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
plan_id: "",
|
plan_id: "",
|
||||||
|
start_date: "",
|
||||||
|
end_date: "",
|
||||||
|
status: "ACTIVE",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscription plans for dropdown
|
// Subscription plans for dropdown
|
||||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
const [isPlansLoading, setIsPlansLoading] = useState(false);
|
|
||||||
|
|
||||||
const [editError, setEditError] = useState("");
|
const [editError, setEditError] = useState("");
|
||||||
const [deleteError, setDeleteError] = useState("");
|
const [deleteError, setDeleteError] = useState("");
|
||||||
@@ -89,7 +109,13 @@ const AllTenants = () => {
|
|||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
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 [search, setSearch] = useState("");
|
||||||
const [tenantNameFilter, setTenantNameFilter] = useState<string[]>([]);
|
const [tenantNameFilter, setTenantNameFilter] = useState<string[]>([]);
|
||||||
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
|
const [tenantDomainFilter, setTenantDomainFilter] = useState<string[]>([]);
|
||||||
@@ -224,14 +250,11 @@ const AllTenants = () => {
|
|||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
const loadPlans = async () => {
|
const loadPlans = async () => {
|
||||||
setIsPlansLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const data = await subscriptionsApi.getAll();
|
const data = await subscriptionsApi.getAll();
|
||||||
if (isMounted) setPlans(data);
|
if (isMounted) setPlans(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load subscription plans:", 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 ?? "",
|
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||||
is_active: tenant.is_active,
|
is_active: tenant.is_active,
|
||||||
plan_id: tenant.plan_id ?? "",
|
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,
|
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
||||||
is_active: editForm.is_active,
|
is_active: editForm.is_active,
|
||||||
plan_id: editForm.plan_id || undefined,
|
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);
|
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
||||||
@@ -409,6 +438,7 @@ const AllTenants = () => {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: "tenant_name",
|
key: "tenant_name",
|
||||||
|
visibilityLabel: "Tenant Name",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Tenant Name
|
Tenant Name
|
||||||
@@ -430,6 +460,7 @@ const AllTenants = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tenant_domain",
|
key: "tenant_domain",
|
||||||
|
visibilityLabel: "Domain",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Domain
|
Domain
|
||||||
@@ -466,6 +497,7 @@ const AllTenants = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "plan_id",
|
key: "plan_id",
|
||||||
|
visibilityLabel: "Plan",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Plan
|
Plan
|
||||||
@@ -496,6 +528,7 @@ const AllTenants = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "is_active",
|
key: "is_active",
|
||||||
|
visibilityLabel: "Status",
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
Status
|
Status
|
||||||
@@ -520,7 +553,27 @@ const AllTenants = () => {
|
|||||||
),
|
),
|
||||||
searchable: false,
|
searchable: false,
|
||||||
render: (row) => (
|
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
|
<CustomTable
|
||||||
data={tenants}
|
data={tenants}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="tenants-table-columns"
|
||||||
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||||
|
exportEnabled={false}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
manualPagination={canReadAll}
|
manualPagination={canReadAll}
|
||||||
manualFiltering={canReadAll}
|
manualFiltering={canReadAll}
|
||||||
@@ -669,13 +726,19 @@ const AllTenants = () => {
|
|||||||
<p className="text-sm text-[var(--text-secondary)]"></p>
|
<p className="text-sm text-[var(--text-secondary)]"></p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Status
|
Status
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
<CustomStatus status={selectedTenant.status} />
|
||||||
{selectedTenant.is_active ? "Active" : "Inactive"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
@@ -685,6 +748,22 @@ const AllTenants = () => {
|
|||||||
{getPlanName(selectedTenant.plan_id)}
|
{getPlanName(selectedTenant.plan_id)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
|
||||||
Created
|
Created
|
||||||
@@ -780,15 +859,64 @@ const AllTenants = () => {
|
|||||||
label="Select Plan"
|
label="Select Plan"
|
||||||
name="plan_id"
|
name="plan_id"
|
||||||
value={editForm.plan_id}
|
value={editForm.plan_id}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setEditForm((prev) => ({ ...prev, plan_id: e.target.value }))
|
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) => ({
|
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,
|
value: plan.id,
|
||||||
}))}
|
}))}
|
||||||
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
|
placeholder="Select a subscription plan"
|
||||||
disabled={isPlansLoading}
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from "../../lib/apiClient";
|
import { apiClient } from "../../lib/apiClient";
|
||||||
import type { ColorPalette } from "./ThemeTypes";
|
import type { ColorPalette, ColorPalettePayload } from "./ThemeTypes";
|
||||||
|
|
||||||
export const paletteApi = {
|
export const paletteApi = {
|
||||||
getAllPalettes: async () => {
|
getAllPalettes: async () => {
|
||||||
@@ -10,11 +10,11 @@ export const paletteApi = {
|
|||||||
return await apiClient.get<ColorPalette>(`/api/theme/get/${id}`);
|
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);
|
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);
|
return await apiClient.put<ColorPalette>(`/api/theme/update/${id}`, paletteData);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -42,5 +42,12 @@ export interface ColorPalette {
|
|||||||
tenant_id?: string;
|
tenant_id?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_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 { Plus, Edit2, Trash2 } from "lucide-react";
|
||||||
import { CustomButton } from "../../../components/custom";
|
import { CustomButton } from "../../../components/custom";
|
||||||
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
|
import DataTable, { type ColumnDef } from "../../../components/custom/CustomTable";
|
||||||
import { CustomActionMenu, CustomActionItem, CustomStatus, CustomConfirmationModal } from "../../../components/custom";
|
import { CustomActionMenu, CustomActionItem, CustomStatus, CustomConfirmationModal } from "../../../components/custom";
|
||||||
import { Loader } from "../../../components/custom/CustomLoader";
|
import { Loader } from "../../../components/custom/CustomLoader";
|
||||||
import { paletteApi } from "../PaletteApi";
|
import { paletteApi } from "../PaletteApi";
|
||||||
import type { ColorPalette } from "../ThemeTypes";
|
import type { ColorPalette, ColorPalettePayload } from "../ThemeTypes";
|
||||||
import PaletteForm from "./PaletteForm";
|
import PaletteForm from "./PaletteForm";
|
||||||
import { useTheme } from "../../../context/ThemeContext";
|
import { useTheme } from "../../../context/ThemeContext";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -21,7 +21,7 @@ const AllPalettes: React.FC = () => {
|
|||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const { refreshTheme } = useTheme();
|
const { refreshTheme } = useTheme();
|
||||||
|
|
||||||
const fetchPalettes = async () => {
|
const fetchPalettes = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
try {
|
try {
|
||||||
@@ -34,11 +34,11 @@ const AllPalettes: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPalettes();
|
fetchPalettes();
|
||||||
}, []);
|
}, [fetchPalettes]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setEditingPalette(null);
|
setEditingPalette(null);
|
||||||
@@ -69,7 +69,7 @@ const AllPalettes: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
const handleSubmit = async (data: ColorPalettePayload) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
if (editingPalette) {
|
if (editingPalette) {
|
||||||
@@ -192,6 +192,9 @@ const AllPalettes: React.FC = () => {
|
|||||||
<DataTable
|
<DataTable
|
||||||
data={palettes}
|
data={palettes}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="palettes-table-columns"
|
||||||
|
exportEnabled={true}
|
||||||
enableSearchDropdown={true}
|
enableSearchDropdown={true}
|
||||||
search="name"
|
search="name"
|
||||||
buildSuggestionLabel={(row) => row.name}
|
buildSuggestionLabel={(row) => row.name}
|
||||||
@@ -222,4 +225,3 @@ const AllPalettes: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default AllPalettes;
|
export default AllPalettes;
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import CustomModal from "../../../components/custom/CustomModal";
|
import CustomModal from "../../../components/custom/CustomModal";
|
||||||
import CustomInput from "../../../components/custom/CustomInput";
|
import CustomInput from "../../../components/custom/CustomInput";
|
||||||
import {CustomButton} from "../../../components/custom";
|
import {CustomButton} from "../../../components/custom";
|
||||||
import type { ColorPalette } from "../ThemeTypes";
|
import type { ColorPalette, ColorPalettePayload, ColorSet } from "../ThemeTypes";
|
||||||
|
|
||||||
interface PaletteFormProps {
|
interface PaletteFormProps {
|
||||||
initialData?: ColorPalette | null;
|
initialData?: ColorPalette | null;
|
||||||
onSubmit: (data: any) => Promise<void>;
|
onSubmit: (data: ColorPalettePayload) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -37,12 +37,12 @@ const colorFields = [
|
|||||||
{ key: "table_header_bg" },
|
{ key: "table_header_bg" },
|
||||||
{ key: "table_row_hover" },
|
{ key: "table_row_hover" },
|
||||||
{ key: "table_border" },
|
{ key: "table_border" },
|
||||||
];
|
] as const satisfies Array<{ key: keyof ColorSet }>;
|
||||||
|
|
||||||
const emptyColors = colorFields.reduce((acc, field) => {
|
const emptyColors = colorFields.reduce((acc, field) => {
|
||||||
acc[field.key] = "#000000";
|
acc[field.key] = "#000000";
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>);
|
}, {} as ColorSet);
|
||||||
|
|
||||||
const PaletteForm: React.FC<PaletteFormProps> = ({
|
const PaletteForm: React.FC<PaletteFormProps> = ({
|
||||||
initialData,
|
initialData,
|
||||||
@@ -59,7 +59,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
|||||||
watch,
|
watch,
|
||||||
reset,
|
reset,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm<ColorPalettePayload>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
@@ -68,6 +68,8 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchedColors = watch("colors");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
reset({
|
reset({
|
||||||
@@ -86,7 +88,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [initialData, isOpen, reset]);
|
}, [initialData, isOpen, reset]);
|
||||||
|
|
||||||
const handleFormSubmit = async (data: any) => {
|
const handleFormSubmit = async (data: ColorPalettePayload) => {
|
||||||
await onSubmit(data);
|
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">
|
<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) => {
|
{colorFields.map((field) => {
|
||||||
const currentColor = watch(`colors.${field.key}` as any);
|
const currentColor = watchedColors[field.key];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={field.key} className="flex items-center gap-2">
|
<div key={field.key} className="flex items-center gap-2">
|
||||||
@@ -169,7 +171,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
|||||||
value={currentColor}
|
value={currentColor}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setValue(
|
setValue(
|
||||||
`colors.${field.key}` as any,
|
`colors.${field.key}`,
|
||||||
e.target.value,
|
e.target.value,
|
||||||
{ shouldDirty: true }
|
{ shouldDirty: true }
|
||||||
);
|
);
|
||||||
@@ -178,7 +180,7 @@ const PaletteForm: React.FC<PaletteFormProps> = ({
|
|||||||
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
label={t(`theme:colors.${field.key}`)}
|
label={t(`theme:colors.${field.key}`)}
|
||||||
{...register(`colors.${field.key}` as any, {
|
{...register(`colors.${field.key}`, {
|
||||||
required: t('theme:form.fields.required'),
|
required: t('theme:form.fields.required'),
|
||||||
})}
|
})}
|
||||||
placeholder="#000000"
|
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 type { Tenant } from "../../tenants/TenantsTypes";
|
||||||
import { useDebounce } from "../../../components/hooks/useDebounce";
|
import { useDebounce } from "../../../components/hooks/useDebounce";
|
||||||
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
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) =>
|
const formatName = (firstName: string, lastName?: string | null) =>
|
||||||
[firstName, lastName].filter(Boolean).join(" ");
|
[firstName, lastName].filter(Boolean).join(" ");
|
||||||
@@ -71,7 +76,13 @@ const AllUsers = () => {
|
|||||||
|
|
||||||
// Pagination & filters
|
// Pagination & filters
|
||||||
const [page, setPage] = useState(1);
|
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 [search, setSearch] = useState("");
|
||||||
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
const [nameFilter, setNameFilter] = useState<string[]>([]);
|
||||||
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
const [emailFilter, setEmailFilter] = useState<string[]>([]);
|
||||||
@@ -422,6 +433,7 @@ const AllUsers = () => {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: "first_name",
|
key: "first_name",
|
||||||
|
visibilityLabel: t('columns.name'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.name')}
|
{t('columns.name')}
|
||||||
@@ -442,6 +454,7 @@ const AllUsers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "email",
|
key: "email",
|
||||||
|
visibilityLabel: t('columns.email'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.email')}
|
{t('columns.email')}
|
||||||
@@ -466,6 +479,7 @@ const AllUsers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
|
visibilityLabel: t('columns.status'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.status')}
|
{t('columns.status')}
|
||||||
@@ -491,6 +505,7 @@ const AllUsers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "tenant_id",
|
key: "tenant_id",
|
||||||
|
visibilityLabel: t('columns.tenant'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.tenant')}
|
{t('columns.tenant')}
|
||||||
@@ -516,6 +531,7 @@ const AllUsers = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "role_id",
|
key: "role_id",
|
||||||
|
visibilityLabel: t('columns.role'),
|
||||||
header: (
|
header: (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{t('columns.role')}
|
{t('columns.role')}
|
||||||
@@ -595,6 +611,10 @@ const AllUsers = () => {
|
|||||||
<CustomTable
|
<CustomTable
|
||||||
data={users}
|
data={users}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
columnVisibilityEnabled
|
||||||
|
columnVisibilityStorageKey="users-table-columns"
|
||||||
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
||||||
|
exportEnabled={false}
|
||||||
getRowId={(row) => row.id}
|
getRowId={(row) => row.id}
|
||||||
manualPagination
|
manualPagination
|
||||||
manualFiltering
|
manualFiltering
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import CustomButton from "./CustomButton";
|
import CustomButton from "./CustomButton";
|
||||||
import CustomInput from "./CustomInput";
|
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 { 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(" ");
|
return parts.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,8 +23,10 @@ export type Primitive =
|
|||||||
| Record<string, unknown>;
|
| Record<string, unknown>;
|
||||||
|
|
||||||
export type ColumnDef<T> = {
|
export type ColumnDef<T> = {
|
||||||
|
id?: string;
|
||||||
key: keyof T | string;
|
key: keyof T | string;
|
||||||
header: React.ReactNode;
|
header: React.ReactNode;
|
||||||
|
visibilityLabel?: string;
|
||||||
exportHeader?: string;
|
exportHeader?: string;
|
||||||
render?: (row: T) => React.ReactNode;
|
render?: (row: T) => React.ReactNode;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
@@ -31,10 +38,14 @@ export type DataTableProps<T extends Record<string, unknown>> = {
|
|||||||
columns: Array<ColumnDef<T>>;
|
columns: Array<ColumnDef<T>>;
|
||||||
defaultPageSize?: number;
|
defaultPageSize?: number;
|
||||||
pageSizeOptions?: number[];
|
pageSizeOptions?: number[];
|
||||||
|
exportEnabled?: boolean;
|
||||||
exportFileName?: string;
|
exportFileName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
getRowId?: (row: T, index: number) => string | number;
|
getRowId?: (row: T, index: number) => string | number;
|
||||||
filterControls?: React.ReactNode;
|
filterControls?: React.ReactNode;
|
||||||
|
columnVisibilityEnabled?: boolean;
|
||||||
|
columnVisibilityStorageKey?: string;
|
||||||
|
pageSizeStorageKey?: string;
|
||||||
|
|
||||||
enableSearchDropdown?: boolean;
|
enableSearchDropdown?: boolean;
|
||||||
buildSuggestionLabel?: (row: T) => string;
|
buildSuggestionLabel?: (row: T) => string;
|
||||||
@@ -79,6 +90,19 @@ function downloadCSV(fileName: string, rows: string[]) {
|
|||||||
URL.revokeObjectURL(url);
|
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>>(
|
export function DataTable<T extends Record<string, unknown>>(
|
||||||
props: DataTableProps<T>
|
props: DataTableProps<T>
|
||||||
) {
|
) {
|
||||||
@@ -88,10 +112,14 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
columns,
|
columns,
|
||||||
defaultPageSize = 10,
|
defaultPageSize = 10,
|
||||||
pageSizeOptions = [5, 10, 20, 50],
|
pageSizeOptions = [5, 10, 20, 50],
|
||||||
|
exportEnabled = false,
|
||||||
exportFileName = "export.csv",
|
exportFileName = "export.csv",
|
||||||
className,
|
className,
|
||||||
getRowId,
|
getRowId,
|
||||||
filterControls,
|
filterControls,
|
||||||
|
columnVisibilityEnabled = false,
|
||||||
|
columnVisibilityStorageKey,
|
||||||
|
pageSizeStorageKey,
|
||||||
enableSearchDropdown = false,
|
enableSearchDropdown = false,
|
||||||
buildSuggestionLabel,
|
buildSuggestionLabel,
|
||||||
onSuggestionSelect,
|
onSuggestionSelect,
|
||||||
@@ -112,22 +140,158 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [internalSearch, setInternalSearch] = React.useState("");
|
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 [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 search = manualFiltering && controlledSearch !== undefined ? controlledSearch : internalSearch;
|
||||||
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
|
const pageSize = manualPagination && controlledPageSize !== undefined ? controlledPageSize : internalPageSize;
|
||||||
const page = manualPagination && controlledPage !== undefined ? controlledPage : internalPage;
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!manualPagination) {
|
if (!manualPagination) {
|
||||||
setInternalPage(1);
|
setInternalPage(1);
|
||||||
}
|
}
|
||||||
}, [internalSearch, internalPageSize, manualPagination]);
|
}, [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(
|
const searchableColumns = React.useMemo(
|
||||||
() => columns.filter((c) => c.searchable !== false),
|
() => visibleColumns.filter((c) => c.searchable !== false),
|
||||||
[columns]
|
[visibleColumns]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getCellValue = React.useCallback(
|
const getCellValue = React.useCallback(
|
||||||
@@ -162,6 +326,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
|
|
||||||
const exportCurrentView = () => {
|
const exportCurrentView = () => {
|
||||||
const header = columns
|
const header = columns
|
||||||
|
.filter((c) => resolvedVisibleColumnIds.includes(getColumnId(c)))
|
||||||
.map((c) =>
|
.map((c) =>
|
||||||
toCSVValue(
|
toCSVValue(
|
||||||
c.exportHeader ??
|
c.exportHeader ??
|
||||||
@@ -170,7 +335,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
)
|
)
|
||||||
.join(",");
|
.join(",");
|
||||||
const lines = pageRows.map((row) =>
|
const lines = pageRows.map((row) =>
|
||||||
columns
|
visibleColumns
|
||||||
.map((c) => {
|
.map((c) => {
|
||||||
const value = c.render ? c.render(row) : getCellValue(row, c.key);
|
const value = c.render ? c.render(row) : getCellValue(row, c.key);
|
||||||
if (typeof value === "string" || typeof value === "number")
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -343,19 +576,104 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
{filterControls}
|
{filterControls}
|
||||||
<CustomButton
|
{columnVisibilityEnabled ? (
|
||||||
variant="outlined"
|
<div className="relative" ref={columnsButtonRef}>
|
||||||
size="sm"
|
<CustomButton
|
||||||
onClick={exportCurrentView}
|
variant="outlined"
|
||||||
leftIcon={<Upload size={16} />}
|
size="sm"
|
||||||
>
|
onClick={handleColumnsMenuToggle}
|
||||||
{t('actions.export')}
|
leftIcon={<Columns3 size={16} />}
|
||||||
</CustomButton>
|
>
|
||||||
|
{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>
|
||||||
</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
|
<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"
|
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 }}
|
style={{ maxHeight }}
|
||||||
@@ -363,7 +681,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
<table className="min-w-max w-full text-sm">
|
<table className="min-w-max w-full text-sm">
|
||||||
<thead className="bg-[var(--table-header-bg)] sticky top-0 z-10 shadow-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)]">
|
<tr className="text-left text-xs uppercase tracking-wider text-[var(--text-secondary)]">
|
||||||
{columns.map((c, idx) => (
|
{visibleColumns.map((c, idx) => (
|
||||||
<th
|
<th
|
||||||
key={String(c.key) + idx}
|
key={String(c.key) + idx}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -380,7 +698,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length}
|
colSpan={visibleColumns.length}
|
||||||
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
className="px-6 py-24 text-center text-[var(--text-secondary)]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
@@ -405,7 +723,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
isHighlighted && highlightClassName
|
isHighlighted && highlightClassName
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{columns.map((c, ci) => {
|
{visibleColumns.map((c, ci) => {
|
||||||
const content = c.render
|
const content = c.render
|
||||||
? c.render(row)
|
? c.render(row)
|
||||||
: (getCellValue(row, c.key) as React.ReactNode);
|
: (getCellValue(row, c.key) as React.ReactNode);
|
||||||
@@ -427,7 +745,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
) : (
|
) : (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length}
|
colSpan={visibleColumns.length}
|
||||||
className="px-6 py-12 text-center text-[var(--text-secondary)]"
|
className="px-6 py-12 text-center text-[var(--text-secondary)]"
|
||||||
>
|
>
|
||||||
{t('common.noData')}
|
{t('common.noData')}
|
||||||
@@ -452,6 +770,7 @@ export function DataTable<T extends Record<string, unknown>>(
|
|||||||
value={pageSize}
|
value={pageSize}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newSize = Number(e.target.value);
|
const newSize = Number(e.target.value);
|
||||||
|
persistTablePageSize(pageSizeStorageKey, newSize, pageSizeOptions);
|
||||||
if (manualPagination && onPageSizeChange) {
|
if (manualPagination && onPageSizeChange) {
|
||||||
onPageSizeChange(newSize);
|
onPageSizeChange(newSize);
|
||||||
} else {
|
} 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 { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -146,6 +146,16 @@ const AppSidebar: React.FC = () => {
|
|||||||
[location.pathname]
|
[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(
|
const visibleNavItems = useMemo(
|
||||||
() => navItems.filter((item) => {
|
() => navItems.filter((item) => {
|
||||||
if (!item.access) return true;
|
if (!item.access) return true;
|
||||||
@@ -161,32 +171,6 @@ const AppSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const [openSubmenus, setOpenSubmenus] = useState<Record<string, boolean>>({});
|
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) => {
|
const toggleSubmenu = (name: string) => {
|
||||||
if (!isExpanded && !isMobile) {
|
if (!isExpanded && !isMobile) {
|
||||||
toggleSidebar(); // Auto expand when clicking submenu in collapsed state
|
toggleSidebar(); // Auto expand when clicking submenu in collapsed state
|
||||||
@@ -288,7 +272,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
{visibleNavItems.map((nav) => {
|
{visibleNavItems.map((nav) => {
|
||||||
const active = isActive(nav.path);
|
const active = isActive(nav.path);
|
||||||
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
|
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
|
||||||
const isMenuOpen = openSubmenus[nav.name];
|
const isMenuOpen = openSubmenus[nav.name] || isSubmenuRouteActive(nav.submenu);
|
||||||
|
|
||||||
if (hasSubmenu) {
|
if (hasSubmenu) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -28,13 +28,16 @@
|
|||||||
"export": "تصدير",
|
"export": "تصدير",
|
||||||
"import": "استيراد",
|
"import": "استيراد",
|
||||||
"reset": "إعادة تعيين",
|
"reset": "إعادة تعيين",
|
||||||
|
"clear": "مسح",
|
||||||
"submit": "إرسال",
|
"submit": "إرسال",
|
||||||
|
"apply": "تطبيق",
|
||||||
"close": "إغلاق",
|
"close": "إغلاق",
|
||||||
"confirm": "تأكيد",
|
"confirm": "تأكيد",
|
||||||
"back": "رجوع",
|
"back": "رجوع",
|
||||||
"next": "التالي",
|
"next": "التالي",
|
||||||
"previous": "السابق",
|
"previous": "السابق",
|
||||||
"view": "عرض",
|
"view": "عرض",
|
||||||
|
"columns": "الأعمدة",
|
||||||
"refresh": "تحديث",
|
"refresh": "تحديث",
|
||||||
"signout": "تسجيل الخروج",
|
"signout": "تسجيل الخروج",
|
||||||
"logout": "تسجيل الخروج"
|
"logout": "تسجيل الخروج"
|
||||||
|
|||||||
@@ -28,13 +28,16 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
"clear": "Clear",
|
||||||
"submit": "Submit",
|
"submit": "Submit",
|
||||||
|
"apply": "Apply",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
|
"columns": "Columns",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"signout": "Sign Out",
|
"signout": "Sign Out",
|
||||||
"logout": "Logout"
|
"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