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:
furqan
2026-04-23 10:38:14 +00:00
19 changed files with 790 additions and 92 deletions
+20 -1
View File
@@ -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);
+18 -1
View File
@@ -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
+12
View File
@@ -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">
+144 -16
View File
@@ -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>
+3 -3
View File
@@ -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);
},
+8 -1
View File
@@ -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;
+21 -1
View File
@@ -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
+339 -20
View File
@@ -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;
+12 -28
View File
@@ -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 (
+3
View File
@@ -28,13 +28,16 @@
"export": "تصدير",
"import": "استيراد",
"reset": "إعادة تعيين",
"clear": "مسح",
"submit": "إرسال",
"apply": "تطبيق",
"close": "إغلاق",
"confirm": "تأكيد",
"back": "رجوع",
"next": "التالي",
"previous": "السابق",
"view": "عرض",
"columns": "الأعمدة",
"refresh": "تحديث",
"signout": "تسجيل الخروج",
"logout": "تسجيل الخروج"
+3
View File
@@ -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"
+57
View File
@@ -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.
}
}