From 2a4a9cb260b49c96f3baab45315d5f6fc4bdaaf6 Mon Sep 17 00:00:00 2001 From: Furqan-14 Date: Thu, 9 Apr 2026 12:01:08 +0530 Subject: [PATCH] feat: implemented subscription module --- .../components/GroupedAccessSelector.tsx | 4 +- .../subscriptions/SubscriptionTypes.ts | 43 ++ .../subscriptions/SubscriptionsApi.ts | 76 ++ .../components/AddSubscriptions.tsx | 229 ++++++ .../components/AllSubscriptions.tsx | 666 ++++++++++++++++++ src/application/subscriptions/index.tsx | 13 + src/application/tenants/TenantsTypes.ts | 9 +- .../tenants/components/AddTenants.tsx | 365 ++++++---- .../tenants/components/AllTenants.tsx | 245 ++----- src/components/layout/AppSidebar.tsx | 8 +- src/routes/index.tsx | 2 + 11 files changed, 1332 insertions(+), 328 deletions(-) create mode 100644 src/application/subscriptions/SubscriptionTypes.ts create mode 100644 src/application/subscriptions/SubscriptionsApi.ts create mode 100644 src/application/subscriptions/components/AddSubscriptions.tsx create mode 100644 src/application/subscriptions/components/AllSubscriptions.tsx create mode 100644 src/application/subscriptions/index.tsx diff --git a/src/application/roles/components/GroupedAccessSelector.tsx b/src/application/roles/components/GroupedAccessSelector.tsx index ac7209b..e82c248 100644 --- a/src/application/roles/components/GroupedAccessSelector.tsx +++ b/src/application/roles/components/GroupedAccessSelector.tsx @@ -8,6 +8,7 @@ interface GroupedAccessSelectorProps { selectedIds: string[]; onChange: (ids: string[]) => void; isLoading?: boolean; + title?: string; } export const GroupedAccessSelector = ({ @@ -15,6 +16,7 @@ export const GroupedAccessSelector = ({ selectedIds = [], onChange, isLoading = false, + title = "Role Permissions", }: GroupedAccessSelectorProps) => { const { moduleGroups, childMap, allIds } = useMemo(() => { @@ -289,7 +291,7 @@ export const GroupedAccessSelector = ({
{/* Global Header */}
-

Role Permissions

+

{title}

): string => { + const searchParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + searchParams.append(key, String(value)); + } + }); + const query = searchParams.toString(); + return query ? `?${query}` : ""; +}; + +export const subscriptionsApi = { + getAll: (params?: { is_public?: boolean; status?: string }) => { + const queryString = params ? buildQueryString(params) : ""; + return apiClient.get( + `/api/subscription-plan/all${queryString}` + ); + }, + + getById: (planId: string) => + apiClient.get( + `/api/subscription-plan/get/${planId}` + ), + + create: (payload: SubscriptionPlanCreateRequest) => + apiClient.post("/api/subscription-plan/create", payload, { + successMessage: "Subscription plan created successfully", + errorMessage: "Failed to create subscription plan", + }), + + update: (planId: string, payload: SubscriptionPlanUpdateRequest) => + apiClient.put( + `/api/subscription-plan/update/${planId}`, + payload, + { + successMessage: "Subscription plan updated successfully", + errorMessage: "Failed to update subscription plan", + } + ), + + remove: (planId: string) => + apiClient.delete<{ message: string }>( + `/api/subscription-plan/delete/${planId}`, + { + successMessage: "Subscription plan deleted", + errorMessage: "Failed to delete subscription plan", + } + ), + + getPaginated: (params: { + page?: number; + page_size?: number; + search?: string; + }) => { + const queryString = buildQueryString({ + page: params.page, + page_size: params.page_size, + search: params.search, + }); + return apiClient.get( + `/api/subscription-plan/list${queryString}` + ); + }, + + getAccesses: () => apiClient.get("/api/access/get"), +}; \ No newline at end of file diff --git a/src/application/subscriptions/components/AddSubscriptions.tsx b/src/application/subscriptions/components/AddSubscriptions.tsx new file mode 100644 index 0000000..8a8f3f9 --- /dev/null +++ b/src/application/subscriptions/components/AddSubscriptions.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + CustomButton, + CustomCheckBox, + CustomDropdown, + CustomInput, + CustomBackButton, +} from "../../../components/custom"; +import { subscriptionsApi } from "../SubscriptionsApi"; +import type { SubscriptionPlanCreateRequest } from "../SubscriptionTypes"; +import type { RoleAccess } from "../../roles/RolesTypes"; +import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector"; + +const AddSubscriptions = () => { + const navigate = useNavigate(); + + const [formData, setFormData] = useState({ + name: "", + description: "", + price: undefined, + is_public: true, + status: "active", + access_ids: [], + module_access_ids: [], + }); + + const [allAccesses, setAllAccesses] = useState([]); + const [selectedAccessIds, setSelectedAccessIds] = useState([]); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + let isMounted = true; + + const loadAccesses = async () => { + setIsAccessLoading(true); + try { + const data = await subscriptionsApi.getAccesses(); + if (isMounted) { + setAllAccesses(data); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error + ? error.message + : "Unable to load access options."; + setErrorMessage(message); + } + } finally { + if (isMounted) { + setIsAccessLoading(false); + } + } + }; + + loadAccesses(); + return () => { + isMounted = false; + }; + }, []); + + const handleChange = (event: React.ChangeEvent) => { + const { name, value } = event.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const splitAccessIds = (ids: string[]) => { + const accessIds: string[] = []; + const moduleAccessIds: string[] = []; + const accessMap = new Map(allAccesses.map((a) => [a.id, a])); + + ids.forEach((id) => { + const access = accessMap.get(id); + if (access?.module_id) { + moduleAccessIds.push(id); + } else { + accessIds.push(id); + } + }); + + return { accessIds, moduleAccessIds }; + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setErrorMessage(""); + setIsLoading(true); + + try { + const { accessIds, moduleAccessIds } = splitAccessIds(selectedAccessIds); + + const payload: SubscriptionPlanCreateRequest = { + name: formData.name.trim(), + description: formData.description?.trim() || undefined, + price: formData.price ? Number(formData.price) : undefined, + is_public: formData.is_public, + status: formData.status, + access_ids: accessIds, + module_access_ids: moduleAccessIds, + }; + + await subscriptionsApi.create(payload); + navigate("/subscriptions"); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Unable to create subscription plan."; + setErrorMessage(message); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+ +
+

+ Add Subscription Plan +

+

+ Create a new plan with pricing and access permissions. +

+
+
+
+ +
+
+ + + setFormData((prev) => ({ + ...prev, + price: e.target.value ? Number(e.target.value) : undefined, + })) + } + /> +
+ + + +
+ + setFormData((prev) => ({ ...prev, status: e.target.value })) + } + options={[ + { label: "Active", value: "active" }, + { label: "Inactive", value: "inactive" }, + ]} + /> +
+ + setFormData((prev) => ({ + ...prev, + is_public: e.target.checked, + })) + } + /> +
+
+ +
+ +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ + Create Plan + +
+ +
+ ); +}; + +export default AddSubscriptions; diff --git a/src/application/subscriptions/components/AllSubscriptions.tsx b/src/application/subscriptions/components/AllSubscriptions.tsx new file mode 100644 index 0000000..32fac7d --- /dev/null +++ b/src/application/subscriptions/components/AllSubscriptions.tsx @@ -0,0 +1,666 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { Edit2, Eye, Trash2 } from "lucide-react"; +import { + CustomButton, + CustomCheckBox, + CustomConfirmationModal, + CustomDropdown, + CustomInput, + CustomModal, + CustomTable, + CustomStatus, + CustomLoader, + CustomActionMenu, + CustomActionItem, +} from "../../../components/custom"; +import { GroupedAccessSelector } from "../../roles/components/GroupedAccessSelector"; +import { GroupedAccessViewer } from "../../roles/components/GroupedAccessViewer"; +import type { ColumnDef } from "../../../components/custom/CustomTable"; +import type { SubscriptionPlan, SubscriptionPlanUpdateRequest } from "../SubscriptionTypes"; +import type { RoleAccess } from "../../roles/RolesTypes"; +import { subscriptionsApi } from "../SubscriptionsApi"; +import { ProtectedComponent } from "../../../components/auth/ProtectedComponent"; +import { useAuth } from "../../../context/AuthContext"; +import { useDebounce } from "../../../components/hooks/useDebounce"; + +const formatDate = (dateString?: string | null) => { + if (!dateString) return ""; + try { + const date = new Date(dateString); + if (isNaN(date.getTime())) return dateString; + + const hasTime = dateString.includes("T") || dateString.includes(":"); + const options: Intl.DateTimeFormatOptions = { + day: "numeric", + month: "short", + year: "numeric", + }; + + if (hasTime) { + options.hour = "2-digit"; + options.minute = "2-digit"; + options.hour12 = false; + } + + return date.toLocaleString("en-GB", options); + } catch { + return dateString; + } +}; + +const formatPrice = (price?: number | null) => { + if (price == null) return "-"; + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(price); +}; + +interface EditFormState { + name: string; + description: string; + price: number | undefined; + is_public: boolean; + status: string; +} + +const AllSubscriptions = () => { + const { isLoading: isAuthLoading } = useAuth(); + + const [plans, setPlans] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [search, setSearch] = useState(""); + const [totalRows, setTotalRows] = useState(0); + const [, setTotalPages] = useState(0); + + const debouncedSearch = useDebounce(search, 500); + const searchInputRef = useRef(null); + const prevLoadingRef = useRef(isLoading); + + const [selectedPlan, setSelectedPlan] = useState(null); + const [isViewOpen, setIsViewOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + const [allAccesses, setAllAccesses] = useState([]); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [viewAccessIds, setViewAccessIds] = useState([]); + const [editAccessIds, setEditAccessIds] = useState([]); + const [isDetailLoading, setIsDetailLoading] = useState(false); + + const [editForm, setEditForm] = useState({ + name: "", + description: "", + price: undefined, + is_public: true, + status: "active", + }); + + const [editError, setEditError] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + let isMounted = true; + const loadAccesses = async () => { + setIsAccessLoading(true); + try { + const data = await subscriptionsApi.getAccesses(); + if (isMounted) setAllAccesses(data); + } catch (err) { + console.error("Failed to load accesses:", err); + } finally { + if (isMounted) setIsAccessLoading(false); + } + }; + loadAccesses(); + return () => { isMounted = false; }; + }, []); + + useEffect(() => { + let isMounted = true; + + const loadPlans = async () => { + if (isAuthLoading) return; + + setIsLoading(true); + setErrorMessage(""); + + try { + const response = await subscriptionsApi.getPaginated({ + page, + page_size: pageSize, + search: debouncedSearch || undefined, + }); + + if (isMounted) { + setPlans(response.items); + setTotalRows(response.total); + setTotalPages(response.total_pages); + } + } catch (error) { + if (isMounted) { + const message = + error instanceof Error ? error.message : "Unable to load plans."; + setErrorMessage(message); + } + } finally { + if (isMounted) setIsLoading(false); + } + }; + + loadPlans(); + return () => { isMounted = false; }; + }, [isAuthLoading, page, pageSize, debouncedSearch]); + + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + + useEffect(() => { + if (prevLoadingRef.current && !isLoading && search.trim()) { + searchInputRef.current?.focus({ preventScroll: true }); + } + prevLoadingRef.current = isLoading; + }, [isLoading, search]); + + const viewAccesses = useMemo(() => { + if (!viewAccessIds.length || !allAccesses.length) return []; + const idSet = new Set(viewAccessIds); + return allAccesses.filter((a) => idSet.has(a.id)); + }, [viewAccessIds, allAccesses]); + + const splitAccessIds = useCallback( + (ids: string[]) => { + const accessIds: string[] = []; + const moduleAccessIds: string[] = []; + const accessMap = new Map(allAccesses.map((a) => [a.id, a])); + + ids.forEach((id) => { + const access = accessMap.get(id); + if (access?.module_id) { + moduleAccessIds.push(id); + } else { + accessIds.push(id); + } + }); + + return { accessIds, moduleAccessIds }; + }, + [allAccesses] + ); + + const openView = useCallback(async (plan: SubscriptionPlan) => { + setSelectedPlan(plan); + setViewAccessIds([]); + setIsViewOpen(true); + setIsDetailLoading(true); + try { + const detail = await subscriptionsApi.getById(plan.id); + setViewAccessIds([...detail.access_ids, ...detail.module_access_ids]); + } catch { + setViewAccessIds([]); + } finally { + setIsDetailLoading(false); + } + }, []); + + const openEdit = useCallback(async (plan: SubscriptionPlan) => { + setSelectedPlan(plan); + setEditForm({ + name: plan.name, + description: plan.description ?? "", + price: plan.price ?? undefined, + is_public: plan.is_public, + status: plan.status, + }); + setEditAccessIds([]); + setEditError(""); + setIsEditOpen(true); + try { + const detail = await subscriptionsApi.getById(plan.id); + setEditAccessIds([...detail.access_ids, ...detail.module_access_ids]); + } catch (err) { + setEditError( + err instanceof Error ? err.message : "Failed to load plan details." + ); + } + }, []); + + const openDelete = useCallback((plan: SubscriptionPlan) => { + setSelectedPlan(plan); + setDeleteError(""); + setIsDeleteOpen(true); + }, []); + + const closeView = useCallback(() => { + setIsViewOpen(false); + setSelectedPlan(null); + setViewAccessIds([]); + }, []); + + const closeEdit = useCallback(() => { + setIsEditOpen(false); + setSelectedPlan(null); + setEditError(""); + }, []); + + const closeDelete = useCallback(() => { + setIsDeleteOpen(false); + setSelectedPlan(null); + setDeleteError(""); + }, []); + + const handleEditChange = useCallback( + (event: React.ChangeEvent) => { + const { name, value } = event.target; + setEditForm((prev) => ({ ...prev, [name]: value })); + }, + [] + ); + + const handleUpdate = async (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedPlan) return; + + setIsSaving(true); + setEditError(""); + + try { + const { accessIds, moduleAccessIds } = splitAccessIds(editAccessIds); + + const payload: SubscriptionPlanUpdateRequest = { + name: editForm.name.trim(), + description: editForm.description.trim() || undefined, + price: editForm.price ? Number(editForm.price) : undefined, + is_public: editForm.is_public, + status: editForm.status, + access_ids: accessIds, + module_access_ids: moduleAccessIds, + }; + + const updated = await subscriptionsApi.update(selectedPlan.id, payload); + setPlans((prev) => + prev.map((p) => (p.id === updated.id ? updated : p)) + ); + closeEdit(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to update plan."; + setEditError(message); + } finally { + setIsSaving(false); + } + }; + + const handleDelete = async () => { + if (!selectedPlan) return; + + setIsDeleting(true); + setDeleteError(""); + + try { + await subscriptionsApi.remove(selectedPlan.id); + setPlans((prev) => prev.filter((p) => p.id !== selectedPlan.id)); + closeDelete(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unable to delete plan."; + setDeleteError(message); + } finally { + setIsDeleting(false); + } + }; + + const columns = useMemo>>( + () => [ + { key: "name", header: "Plan Name" }, + { + key: "price", + header: "Price", + searchable: false, + render: (row) => ( + + {formatPrice(row.price)} + + ), + }, + { + key: "status", + header: "Status", + searchable: false, + render: (row) => ( + + ), + }, + { + key: "is_public", + header: "Visibility", + searchable: false, + render: (row) => ( + + {row.is_public ? "Public" : "Private"} + + ), + }, + { + key: "created_at", + header: "Created", + render: (row) => ( +
+ {formatDate(row.created_at)} +
+ ), + }, + { + key: "id", + header: "Action", + searchable: false, + render: (row) => ( + + + openView(row)}> + View Details + + + + + openEdit(row)}> + Edit Plan + + + + + openDelete(row)} + className="text-red-600 hover:text-red-700 hover:bg-red-50" + > + Delete Plan + + + + ), + }, + ], + [openView, openEdit, openDelete] + ); + + return ( +
+
+
+

+ Subscription Plans +

+
+ + + + Add Plan + + +
+ + {isLoading ? ( +
+ +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : ( + row.id} + manualPagination + manualFiltering + totalRows={totalRows} + page={page} + pageSize={pageSize} + search={search} + onPageChange={setPage} + onPageSizeChange={setPageSize} + onSearchChange={setSearch} + searchInputRef={searchInputRef} + /> + )} + + {/* View Modal */} + + Close + + } + > + {selectedPlan ? ( +
+
+
+

+ Name +

+

+ {selectedPlan.name} +

+
+
+

+ Price +

+

+ {formatPrice(selectedPlan.price)} +

+
+
+

+ Status +

+ +
+
+

+ Visibility +

+

+ {selectedPlan.is_public ? "Public" : "Private"} +

+
+ {selectedPlan.description && ( +
+

+ Description +

+

+ {selectedPlan.description} +

+
+ )} +
+

+ Created +

+

+ {formatDate(selectedPlan.created_at)} +

+
+
+

+ Updated +

+

+ {formatDate(selectedPlan.updated_at)} +

+
+
+
+

+ Plan Permissions +

+ {isDetailLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ) : ( +

+ No plan selected. +

+ )} +
+ + {/* Edit Modal */} + + + Cancel + + + Save Changes + + + } + > +
+
+ + + setEditForm((prev) => ({ + ...prev, + price: e.target.value ? Number(e.target.value) : undefined, + })) + } + /> +
+ + + +
+ + setEditForm((prev) => ({ ...prev, status: e.target.value })) + } + options={[ + { label: "Active", value: "active" }, + { label: "Inactive", value: "inactive" }, + ]} + /> +
+ + setEditForm((prev) => ({ + ...prev, + is_public: e.target.checked, + })) + } + /> +
+
+ +
+ +
+ + {editError && ( +
+ {editError} +
+ )} + +
+ + {/* Delete Modal */} + +
+ ); +}; + +export default AllSubscriptions; \ No newline at end of file diff --git a/src/application/subscriptions/index.tsx b/src/application/subscriptions/index.tsx new file mode 100644 index 0000000..d3cb6c6 --- /dev/null +++ b/src/application/subscriptions/index.tsx @@ -0,0 +1,13 @@ +import { Route, Routes } from "react-router-dom"; +import AllSubscriptions from "./components/AllSubscriptions"; +import AddSubscriptions from "./components/AddSubscriptions"; + +const SubscriptionsRoutes: React.FC = () => { + return ( + + } /> + } /> + + ); +}; +export default SubscriptionsRoutes; \ No newline at end of file diff --git a/src/application/tenants/TenantsTypes.ts b/src/application/tenants/TenantsTypes.ts index 0b83589..8f5a69b 100644 --- a/src/application/tenants/TenantsTypes.ts +++ b/src/application/tenants/TenantsTypes.ts @@ -4,11 +4,12 @@ export type Tenant = { tenant_domain: string; tenant_logo_url?: string | null; is_active: boolean; + plan_id?: string | null; created_at: string; updated_at: string; }; -export type TenantModuleCreate = { +export type ModuleEnvironmentAssignment = { module_id: string; environment_slug: string; }; @@ -17,8 +18,8 @@ export type TenantCreateRequest = { tenant_name: string; tenant_domain: string; tenant_logo_url?: string; - is_active?: boolean; - modules?: TenantModuleCreate[]; + plan_id: string; + module_environments?: ModuleEnvironmentAssignment[]; }; export type TenantUpdateRequest = { @@ -26,7 +27,7 @@ export type TenantUpdateRequest = { tenant_domain?: string; tenant_logo_url?: string | null; is_active?: boolean; - modules?: TenantModuleCreate[]; + plan_id?: string; }; export type TenantPaginatedResponse = { diff --git a/src/application/tenants/components/AddTenants.tsx b/src/application/tenants/components/AddTenants.tsx index c08b0a8..5375b3f 100644 --- a/src/application/tenants/components/AddTenants.tsx +++ b/src/application/tenants/components/AddTenants.tsx @@ -4,9 +4,12 @@ import { CustomButton, CustomInput, CustomDropdown } from "../../../components/c import CustomBackButton from "../../../components/custom/CustomBackButton"; import { CustomLoader } from "../../../components/custom"; import { tenantsApi } from "../TenantsApi"; -import type { Tenant, TenantCreateRequest } from "../TenantsTypes"; +import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment } from "../TenantsTypes"; import { useAuth } from "../../../context/AuthContext"; -import { useModuleApi } from "../../modules/admin/hooks/useModuleApi"; +import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi"; +import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes"; +import type { RoleAccess } from "../../roles/RolesTypes"; +import { adminModuleApi } from "../../modules/admin/AdminModuleApi"; import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes"; import { AlertCircle } from "lucide-react"; @@ -14,19 +17,26 @@ const AddTenants = () => { const { hasAccess, isLoading: isAuthLoading } = useAuth(); const canCreateTenant = hasAccess("superadmin.tenant.create"); const navigate = useNavigate(); - const { listModules, listEnvironments, loading: isModulesLoading } = useModuleApi(); - - const [formData, setFormData] = useState({ - tenant_name: "", - tenant_domain: "", - tenant_logo_url: "", - modules: [] - }); - const [availableModules, setAvailableModules] = useState([]); + const [tenantName, setTenantName] = useState(""); + const [tenantDomain, setTenantDomain] = useState(""); + const [tenantLogoUrl, setTenantLogoUrl] = useState(""); + const [selectedPlanId, setSelectedPlanId] = useState(""); + const [moduleEnvAssignments, setModuleEnvAssignments] = useState([]); + + const [plans, setPlans] = useState([]); + const [isPlansLoading, setIsPlansLoading] = useState(false); + + const [allAccesses, setAllAccesses] = useState([]); + + const [allModules, setAllModules] = useState([]); + const [moduleEnvironments, setModuleEnvironments] = useState>({}); const [loadingEnvironments, setLoadingEnvironments] = useState>({}); + const [planModules, setPlanModules] = useState<{ module_id: string; module_name: string }[]>([]); + const [isPlanModulesLoading, setIsPlanModulesLoading] = useState(false); + const [currentTenant, setCurrentTenant] = useState(null); const [isTenantLoading, setIsTenantLoading] = useState(true); const [tenantError, setTenantError] = useState(""); @@ -45,104 +55,133 @@ const AddTenants = () => { setIsTenantLoading(true); try { const data = await tenantsApi.getMine(); - if (isMounted) { - setCurrentTenant(data); - } + if (isMounted) setCurrentTenant(data); } catch (error) { if (isMounted) { - const message = - error instanceof Error - ? error.message - : "Unable to load tenant."; - setTenantError(message); + setTenantError( + error instanceof Error ? error.message : "Unable to load tenant." + ); } } finally { - if (isMounted) { - setIsTenantLoading(false); - } + if (isMounted) setIsTenantLoading(false); } }; loadTenant(); - - return () => { - isMounted = false; - }; + return () => { isMounted = false; }; }, [canCreateTenant, isAuthLoading]); useEffect(() => { - if (canCreateTenant) { - listModules().then((data) => { - if (data) setAvailableModules(data); - }); + if (!canCreateTenant) return; + + let isMounted = true; + + const loadData = async () => { + setIsPlansLoading(true); + try { + const [plansData, accessesData, modulesData] = await Promise.all([ + subscriptionsApi.getAll({ status: "active" }), + subscriptionsApi.getAccesses(), + adminModuleApi.listModules(), + ]); + if (isMounted) { + setPlans(plansData); + setAllAccesses(accessesData); + setAllModules(modulesData); + } + } catch (error) { + console.error("Failed to load data:", error); + } finally { + if (isMounted) setIsPlansLoading(false); } + }; + + loadData(); + return () => { isMounted = false; }; }, [canCreateTenant]); - const handleChange = (event: React.ChangeEvent) => { - const { name, value } = event.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; + useEffect(() => { + if (!selectedPlanId || allAccesses.length === 0 || allModules.length === 0) { + setPlanModules([]); + setModuleEnvAssignments([]); + return; + } - const fetchModuleEnvironments = async (moduleId: string) => { - // Return cached if available - if (moduleEnvironments[moduleId]) return moduleEnvironments[moduleId]; + let isMounted = true; - setLoadingEnvironments(prev => ({ ...prev, [moduleId]: true })); + const resolvePlanModules = async () => { + setIsPlanModulesLoading(true); try { - const envs = await listEnvironments(moduleId); - if (envs) { - setModuleEnvironments(prev => ({ ...prev, [moduleId]: envs })); - return envs; + const planDetail = await subscriptionsApi.getById(selectedPlanId); + + const moduleAccessIdSet = new Set(planDetail.module_access_ids); + const moduleIdsFromPlan = new Set(); + + allAccesses.forEach((access) => { + if (access.module_id && moduleAccessIdSet.has(access.id)) { + moduleIdsFromPlan.add(access.module_id); } + }); + + const moduleMap = new Map(allModules.map((m) => [m.id, m])); + const resolvedModules: { module_id: string; module_name: string }[] = []; + + moduleIdsFromPlan.forEach((modId) => { + const mod = moduleMap.get(modId); + if (mod) { + resolvedModules.push({ module_id: mod.id, module_name: mod.module_name }); + } + }); + + if (isMounted) { + setPlanModules(resolvedModules); + + const newAssignments: ModuleEnvironmentAssignment[] = []; + + await Promise.all( + resolvedModules.map(async (mod) => { + if (!moduleEnvironments[mod.module_id]) { + setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: true })); + try { + const envs = await adminModuleApi.listEnvironments(mod.module_id); + if (isMounted) { + setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: envs })); + const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || ""; + newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv }); + } + } catch { + if (isMounted) { + setModuleEnvironments((prev) => ({ ...prev, [mod.module_id]: [] })); + newAssignments.push({ module_id: mod.module_id, environment_slug: "" }); + } + } finally { + if (isMounted) setLoadingEnvironments((prev) => ({ ...prev, [mod.module_id]: false })); + } + } else { + const envs = moduleEnvironments[mod.module_id]; + const defaultEnv = envs.find((e) => e.is_default)?.slug || envs[0]?.slug || ""; + newAssignments.push({ module_id: mod.module_id, environment_slug: defaultEnv }); + } + }) + ); + + if (isMounted) setModuleEnvAssignments(newAssignments); + } + } catch (error) { + console.error("Failed to resolve plan modules:", error); } finally { - setLoadingEnvironments(prev => ({ ...prev, [moduleId]: false })); + if (isMounted) setIsPlanModulesLoading(false); } - return []; - }; + }; - const handleModuleToggle = async (moduleId: string) => { - setFormData(prev => { - const currentModules = prev.modules || []; - const exists = currentModules.find(m => m.module_id === moduleId); - - if (exists) { - return { - ...prev, - modules: currentModules.filter(m => m.module_id !== moduleId) - }; - } else { - return { - ...prev, - modules: [...currentModules, { module_id: moduleId, environment_slug: '' }] - }; - } - }); - - const currentModules = formData.modules || []; - const isAdding = !currentModules.find(m => m.module_id === moduleId); - - if (isAdding) { - const envs = await fetchModuleEnvironments(moduleId); - const defaultEnv = envs.find(e => e.is_default)?.slug || envs[0]?.slug || ''; - - if (defaultEnv) { - setFormData(prev => ({ - ...prev, - modules: (prev.modules || []).map(m => - m.module_id === moduleId ? { ...m, environment_slug: defaultEnv } : m - ) - })); - } - } - }; + resolvePlanModules(); + return () => { isMounted = false; }; + }, [selectedPlanId, allAccesses, allModules]); const handleEnvironmentChange = (moduleId: string, slug: string) => { - setFormData(prev => ({ - ...prev, - modules: (prev.modules || []).map(m => - m.module_id === moduleId ? { ...m, environment_slug: slug } : m - ) - })); + setModuleEnvAssignments((prev) => + prev.map((a) => (a.module_id === moduleId ? { ...a, environment_slug: slug } : a)) + ); }; const handleSubmit = async (event: React.FormEvent) => { @@ -152,10 +191,11 @@ const AddTenants = () => { try { const payload: TenantCreateRequest = { - tenant_name: formData.tenant_name.trim(), - tenant_domain: formData.tenant_domain.trim(), - tenant_logo_url: formData.tenant_logo_url?.trim() || undefined, - modules: formData.modules + tenant_name: tenantName.trim(), + tenant_domain: tenantDomain.trim(), + tenant_logo_url: tenantLogoUrl.trim() || undefined, + plan_id: selectedPlanId, + module_environments: moduleEnvAssignments.filter((a) => a.environment_slug), }; await tenantsApi.create(payload); @@ -180,7 +220,7 @@ const AddTenants = () => {

{canCreateTenant - ? "Create a new tenant with domain and logo details." + ? "Create a new tenant with domain and subscription plan." : "Your account is assigned to this tenant."}

@@ -234,16 +274,16 @@ const AddTenants = () => { label="Tenant Name" name="tenant_name" placeholder="Enter tenant name" - value={formData.tenant_name} - onChange={handleChange} + value={tenantName} + onChange={(e) => setTenantName(e.target.value)} required /> setTenantDomain(e.target.value)} required />
@@ -252,70 +292,85 @@ const AddTenants = () => { label="Tenant Logo URL" name="tenant_logo_url" placeholder="https://" - value={formData.tenant_logo_url} - onChange={handleChange} + value={tenantLogoUrl} + onChange={(e) => setTenantLogoUrl(e.target.value)} /> - +
-

Module Provisioning

- {isModulesLoading ? ( -
Loading modules...
- ) : availableModules.length === 0 ? ( -
- - No modules available for provisioning. -
- ) : ( -
- {availableModules.map(module => { - const isSelected = formData.modules?.some(m => m.module_id === module.id); - const selectedConfig = formData.modules?.find(m => m.module_id === module.id); - const moduleEnvs = moduleEnvironments[module.id] || []; - const isLoadingEnvs = loadingEnvironments[module.id]; - - return ( -
-
-
- handleModuleToggle(module.id)} - className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer" - /> - -
- - {isSelected && ( -
- handleEnvironmentChange(module.id, e.target.value)} - options={moduleEnvs.map(env => ({ - label: `${env.slug} (${env.trust_type})`, - value: env.slug - }))} - placeholder={isLoadingEnvs ? "Loading..." : "Select Environment"} - disabled={isLoadingEnvs || moduleEnvs.length === 0} - /> - {moduleEnvs.length === 0 && !isLoadingEnvs && ( -
No environments found
- )} -
- )} -
-
- ); - })} -
- )} +

Subscription Plan

+ setSelectedPlanId(e.target.value)} + options={plans.map((plan) => ({ + label: `${plan.name}${plan.price != null ? ` — ${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`, + value: plan.id, + }))} + placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"} + disabled={isPlansLoading} + required + />
+ {/* Module Environment Assignment — appears after plan selection */} + {selectedPlanId && ( +
+

Module Environments

+

+ Select the environment for each module included in this plan. +

+ + {isPlanModulesLoading ? ( +
Resolving plan modules...
+ ) : planModules.length === 0 ? ( +
+ + This plan has no module-level accesses configured. +
+ ) : ( +
+ {planModules.map((mod) => { + const envs = moduleEnvironments[mod.module_id] || []; + const isLoadingEnv = loadingEnvironments[mod.module_id]; + const assignment = moduleEnvAssignments.find((a) => a.module_id === mod.module_id); + + return ( +
+
+
+
+
{mod.module_name}
+
+
+
+ handleEnvironmentChange(mod.module_id, e.target.value)} + options={envs.map((env) => ({ + label: `${env.slug}${env.is_default ? " (default)" : ""}`, + value: env.slug, + }))} + placeholder={isLoadingEnv ? "Loading..." : "Select Environment"} + disabled={isLoadingEnv || envs.length === 0} + /> + {envs.length === 0 && !isLoadingEnv && ( +
No environments found
+ )} +
+
+
+ ); + })} +
+ )} +
+ )} + {errorMessage && (
{errorMessage} diff --git a/src/application/tenants/components/AllTenants.tsx b/src/application/tenants/components/AllTenants.tsx index 6026e16..4ab8808 100644 --- a/src/application/tenants/components/AllTenants.tsx +++ b/src/application/tenants/components/AllTenants.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; -import { Edit2, Eye, Trash2, AlertCircle } from "lucide-react"; +import { Edit2, Eye, Trash2 } from "lucide-react"; import { CustomButton, CustomCheckBox, CustomConfirmationModal, + CustomDropdown, CustomInput, CustomModal, CustomTable, @@ -12,14 +13,13 @@ import { CustomLoader, CustomActionMenu, CustomActionItem, - CustomDropdown } from "../../../components/custom"; import type { ColumnDef } from "../../../components/custom/CustomTable"; -import type { Tenant, TenantUpdateRequest, TenantModuleCreate } from "../TenantsTypes"; +import type { Tenant, TenantUpdateRequest } from "../TenantsTypes"; import { tenantsApi } from "../TenantsApi"; import { useAuth } from "../../../context/AuthContext"; -import { useModuleApi } from "../../modules/admin/hooks/useModuleApi"; -import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes"; +import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi"; +import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes"; function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); @@ -76,7 +76,7 @@ interface EditFormState { tenant_domain: string; tenant_logo_url: string; is_active: boolean; - modules: TenantModuleCreate[]; + plan_id: string; } const AllTenants = () => { @@ -89,21 +89,18 @@ const AllTenants = () => { const [isViewOpen, setIsViewOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); - + const [editForm, setEditForm] = useState({ tenant_name: "", tenant_domain: "", tenant_logo_url: "", is_active: true, - modules: [] + plan_id: "", }); - // Module Management State - const { listModules, listEnvironments, listTenantModules } = useModuleApi(); - const [availableModules, setAvailableModules] = useState([]); - const [moduleEnvironments, setModuleEnvironments] = useState>({}); - const [loadingEnvironments, setLoadingEnvironments] = useState>({}); - const [isLoadingModules, setIsLoadingModules] = useState(false); + // Subscription plans for dropdown + const [plans, setPlans] = useState([]); + const [isPlansLoading, setIsPlansLoading] = useState(false); const [editError, setEditError] = useState(""); const [deleteError, setDeleteError] = useState(""); @@ -191,70 +188,52 @@ const AllTenants = () => { }, [debouncedSearch, statusFilter]); useEffect(() => { - if (hasAccess("superadmin.tenant.update")) { - listModules().then((data) => { - if (data) setAvailableModules(data); - }); + if (!hasAccess("superadmin.tenant.update")) return; + + let isMounted = true; + const loadPlans = async () => { + setIsPlansLoading(true); + try { + const data = await subscriptionsApi.getAll({ status: "active" }); + if (isMounted) setPlans(data); + } catch (error) { + console.error("Failed to load subscription plans:", error); + } finally { + if (isMounted) setIsPlansLoading(false); } + }; + + loadPlans(); + return () => { isMounted = false; }; }, [hasAccess]); + const getPlanName = useCallback( + (planId?: string | null) => { + if (!planId) return "-"; + const found = plans.find((p) => p.id === planId); + return found ? found.name : "-"; + }, + [plans] + ); + const openView = useCallback((tenant: Tenant) => { setSelectedTenant(tenant); setIsViewOpen(true); }, []); - const fetchModuleEnvironments = async (moduleId: string) => { - if (moduleEnvironments[moduleId]) return moduleEnvironments[moduleId]; - setLoadingEnvironments(prev => ({ ...prev, [moduleId]: true })); - try { - const envs = await listEnvironments(moduleId); - if (envs) { - setModuleEnvironments(prev => ({ ...prev, [moduleId]: envs })); - return envs; - } - } finally { - setLoadingEnvironments(prev => ({ ...prev, [moduleId]: false })); - } - return []; - }; - - const openEdit = useCallback(async (tenant: Tenant) => { + const openEdit = useCallback((tenant: Tenant) => { setSelectedTenant(tenant); setIsEditOpen(true); setEditError(""); - setIsLoadingModules(true); - - const initialForm: EditFormState = { + + setEditForm({ tenant_name: tenant.tenant_name, tenant_domain: tenant.tenant_domain, tenant_logo_url: tenant.tenant_logo_url ?? "", is_active: tenant.is_active, - modules: [] - }; - setEditForm(initialForm); - - try { - const assigned = await listTenantModules(tenant.id); - if (assigned) { - const activeModules: TenantModuleCreate[] = assigned - .filter(tm => tm.is_active) - .map(tm => ({ - module_id: tm.module_id, - environment_slug: tm.assigned_environment_slug - })); - - setEditForm(prev => ({ ...prev, modules: activeModules })); - - assigned.forEach(tm => { - fetchModuleEnvironments(tm.module_id); - }); - } - } catch (e) { - console.error("Failed to load assigned modules", e); - } finally { - setIsLoadingModules(false); - } - }, [listTenantModules]); + plan_id: tenant.plan_id ?? "", + }); + }, []); const openDelete = useCallback((tenant: Tenant) => { setSelectedTenant(tenant); @@ -294,49 +273,6 @@ const AllTenants = () => { [] ); - const handleModuleToggle = async (moduleId: string) => { - setEditForm(prev => { - const currentModules = prev.modules; - const exists = currentModules.find(m => m.module_id === moduleId); - - if (exists) { - return { - ...prev, - modules: currentModules.filter(m => m.module_id !== moduleId) - }; - } else { - return { - ...prev, - modules: [...currentModules, { module_id: moduleId, environment_slug: '' }] - }; - } - }); - - const isAdding = !editForm.modules.find(m => m.module_id === moduleId); - if (isAdding) { - const envs = await fetchModuleEnvironments(moduleId); - const defaultEnv = envs.find(e => e.is_default)?.slug || envs[0]?.slug || ''; - - if (defaultEnv) { - setEditForm(prev => ({ - ...prev, - modules: prev.modules.map(m => - m.module_id === moduleId ? { ...m, environment_slug: defaultEnv } : m - ) - })); - } - } - }; - - const handleEnvironmentChange = (moduleId: string, slug: string) => { - setEditForm(prev => ({ - ...prev, - modules: prev.modules.map(m => - m.module_id === moduleId ? { ...m, environment_slug: slug } : m - ) - })); - }; - const handleUpdate = async (event: React.FormEvent) => { event.preventDefault(); if (!selectedTenant) return; @@ -352,7 +288,7 @@ const AllTenants = () => { tenant_domain: editForm.tenant_domain.trim(), tenant_logo_url: editForm.tenant_logo_url.trim() || null, is_active: editForm.is_active, - modules: editForm.modules + plan_id: editForm.plan_id || undefined, }; const updatedTenant = await tenantsApi.update(tenantId, payload); @@ -381,7 +317,6 @@ const AllTenants = () => { const handleDelete = async () => { if (!selectedTenant) return; - // Capture the ID securely const tenantId = selectedTenant.id; setDeleteError(""); @@ -423,6 +358,16 @@ const AllTenants = () => { ), }, + { + key: "plan_id", + header: "Plan", + searchable: false, + render: (row) => ( + + {getPlanName(row.plan_id)} + + ), + }, { key: "is_active", header: "Status", @@ -476,7 +421,7 @@ const AllTenants = () => { ), }, ], - [openDelete, openEdit, openView] + [openDelete, openEdit, openView, getPlanName] ); // Status filter control @@ -588,6 +533,14 @@ const AllTenants = () => { {selectedTenant.is_active ? "Active" : "Inactive"}

+
+

+ Subscription Plan +

+

+ {getPlanName(selectedTenant.plan_id)} +

+

Created @@ -676,65 +629,23 @@ const AllTenants = () => { onChange={handleStatusChange} />

- +
-

Module Provisioning

- {isLoadingModules ? ( -
Loading modules...
- ) : availableModules.length === 0 ? ( -
- - No modules available for provisioning. -
- ) : ( -
- {availableModules.map(module => { - const isSelected = editForm.modules?.some(m => m.module_id === module.id); - const selectedConfig = editForm.modules?.find(m => m.module_id === module.id); - const moduleEnvs = moduleEnvironments[module.id] || []; - const isLoadingEnvs = loadingEnvironments[module.id]; - - return ( -
-
-
- handleModuleToggle(module.id)} - className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer" - /> - -
- - {isSelected && ( -
- handleEnvironmentChange(module.id, e.target.value)} - options={moduleEnvs.map(env => ({ - label: `${env.slug} (${env.trust_type})`, - value: env.slug - }))} - placeholder={isLoadingEnvs ? "Loading..." : "Select Environment"} - disabled={isLoadingEnvs || moduleEnvs.length === 0} - /> - {moduleEnvs.length === 0 && !isLoadingEnvs && ( -
No environments found
- )} -
- )} -
-
- ); - })} -
- )} +

Subscription Plan

+ + setEditForm((prev) => ({ ...prev, plan_id: e.target.value })) + } + options={plans.map((plan) => ({ + label: `${plan.name}${plan.price != null ? ` — ${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`, + value: plan.id, + }))} + placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"} + disabled={isPlansLoading} + />
{editError && ( diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 3ea5741..d992099 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -14,7 +14,7 @@ import { Settings, ChevronDown, ChevronRight, - + CreditCard, } from "lucide-react"; import { useSidebar } from "../../context/SidebarContext"; import { usePermission } from "../../lib/usePermission"; @@ -51,6 +51,12 @@ const navItems: NavItem[] = [ path: "/tenants", access: "superadmin.tenant.read", }, + { + icon: , + name: "Subscriptions", + path: "/subscriptions", + access: "superadmin.plan.read", + }, { icon: , name: "Roles", diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 32bc1f6..41d69e6 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -14,6 +14,7 @@ import SettingsPage from "../application/settings/SettingsPage"; import Users from "../application/users"; import Modules from "../application/modules/admin"; import TenantModuleAssignment from "../application/modules/admin/components/TenantModuleAssignment"; +import Subscriptions from "../application/subscriptions"; const AppRoutes = () => { return ( @@ -28,6 +29,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> } /> } />