Merge pull request 'feat: implemented subscription module' (#21) from furqan into dev

Reviewed-on: https://gitea.maskantech.in/gitea_admin/saas_frontend/pulls/21
This commit is contained in:
furqan
2026-04-09 06:31:58 +00:00
11 changed files with 1332 additions and 328 deletions
@@ -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 = ({
<div className="space-y-4 pt-2">
{/* Global Header */}
<div className="flex items-center justify-between border-b pb-4 mb-6">
<h3 className="text-lg font-semibold text-gray-900">Role Permissions</h3>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<div className="flex items-center gap-2">
<input
type="checkbox"
@@ -0,0 +1,43 @@
export type SubscriptionPlan = {
id: string;
name: string;
description?: string | null;
price?: number | null;
is_public: boolean;
status: string;
created_at: string;
updated_at: string;
};
export type SubscriptionPlanDetail = SubscriptionPlan & {
access_ids: string[];
module_access_ids: string[];
};
export type SubscriptionPlanCreateRequest = {
name: string;
description?: string;
price?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
module_access_ids?: string[];
};
export type SubscriptionPlanUpdateRequest = {
name?: string;
description?: string;
price?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
module_access_ids?: string[];
};
export type SubscriptionPlanPaginatedResponse = {
items: SubscriptionPlan[];
total: number;
page: number;
page_size: number;
total_pages: number;
};
@@ -0,0 +1,76 @@
import { apiClient } from "../../lib/apiClient";
import type {
SubscriptionPlan,
SubscriptionPlanDetail,
SubscriptionPlanCreateRequest,
SubscriptionPlanUpdateRequest,
SubscriptionPlanPaginatedResponse,
} from "./SubscriptionTypes";
import type { RoleAccess } from "../roles/RolesTypes";
const buildQueryString = (params: Record<string, any>): 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<SubscriptionPlan[]>(
`/api/subscription-plan/all${queryString}`
);
},
getById: (planId: string) =>
apiClient.get<SubscriptionPlanDetail>(
`/api/subscription-plan/get/${planId}`
),
create: (payload: SubscriptionPlanCreateRequest) =>
apiClient.post<SubscriptionPlan>("/api/subscription-plan/create", payload, {
successMessage: "Subscription plan created successfully",
errorMessage: "Failed to create subscription plan",
}),
update: (planId: string, payload: SubscriptionPlanUpdateRequest) =>
apiClient.put<SubscriptionPlan>(
`/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<SubscriptionPlanPaginatedResponse>(
`/api/subscription-plan/list${queryString}`
);
},
getAccesses: () => apiClient.get<RoleAccess[]>("/api/access/get"),
};
@@ -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<SubscriptionPlanCreateRequest>({
name: "",
description: "",
price: undefined,
is_public: true,
status: "active",
access_ids: [],
module_access_ids: [],
});
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [selectedAccessIds, setSelectedAccessIds] = useState<string[]>([]);
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<HTMLInputElement>) => {
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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-4">
<CustomBackButton to="/subscriptions" tooltip="Back to Subscriptions" />
<div>
<h1 className="text-2xl font-semibold text-gray-900">
Add Subscription Plan
</h1>
<p className="text-sm text-gray-500">
Create a new plan with pricing and access permissions.
</p>
</div>
</div>
</div>
<form
onSubmit={handleSubmit}
className="space-y-6 rounded-lg border border-gray-200 bg-white p-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Plan Name"
name="name"
placeholder="Enter plan name"
value={formData.name}
onChange={handleChange}
required
/>
<CustomInput
label="Price"
name="price"
type="number"
placeholder="0.00"
value={formData.price ?? ""}
onChange={(e) =>
setFormData((prev) => ({
...prev,
price: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
</div>
<CustomInput
label="Description"
name="description"
placeholder="Enter plan description"
value={formData.description ?? ""}
onChange={handleChange}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomDropdown
label="Status"
name="status"
value={formData.status ?? "active"}
onChange={(e) =>
setFormData((prev) => ({ ...prev, status: e.target.value }))
}
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]}
/>
<div className="flex items-end pb-1">
<CustomCheckBox
label="Publicly Visible"
name="is_public"
checked={formData.is_public ?? true}
onChange={(e) =>
setFormData((prev) => ({
...prev,
is_public: e.target.checked,
}))
}
/>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<GroupedAccessSelector
allAccesses={allAccesses}
selectedIds={selectedAccessIds}
onChange={setSelectedAccessIds}
isLoading={isAccessLoading}
title="Plan Permissions"
/>
</div>
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
</div>
)}
<div className="flex justify-end pt-4">
<CustomButton
type="submit"
variant="primary"
disabled={isLoading}
loading={isLoading}
>
Create Plan
</CustomButton>
</div>
</form>
</div>
);
};
export default AddSubscriptions;
@@ -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<SubscriptionPlan[]>([]);
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<HTMLInputElement>(null);
const prevLoadingRef = useRef(isLoading);
const [selectedPlan, setSelectedPlan] = useState<SubscriptionPlan | null>(null);
const [isViewOpen, setIsViewOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [isAccessLoading, setIsAccessLoading] = useState(false);
const [viewAccessIds, setViewAccessIds] = useState<string[]>([]);
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [editForm, setEditForm] = useState<EditFormState>({
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<HTMLInputElement>) => {
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<Array<ColumnDef<SubscriptionPlan>>>(
() => [
{ key: "name", header: "Plan Name" },
{
key: "price",
header: "Price",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{formatPrice(row.price)}
</span>
),
},
{
key: "status",
header: "Status",
searchable: false,
render: (row) => (
<CustomStatus
status={row.status === "active" ? "Active" : "Inactive"}
/>
),
},
{
key: "is_public",
header: "Visibility",
searchable: false,
render: (row) => (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
row.is_public
? "bg-blue-50 text-blue-700"
: "bg-gray-100 text-gray-600"
}`}
>
{row.is_public ? "Public" : "Private"}
</span>
),
},
{
key: "created_at",
header: "Created",
render: (row) => (
<div className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(row.created_at)}
</div>
),
},
{
key: "id",
header: "Action",
searchable: false,
render: (row) => (
<CustomActionMenu>
<ProtectedComponent requiredAccess="superadmin.plan.read">
<CustomActionItem onClick={() => openView(row)}>
<Eye size={16} className="mr-2" /> View Details
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.plan.update">
<CustomActionItem onClick={() => openEdit(row)}>
<Edit2 size={16} className="mr-2" /> Edit Plan
</CustomActionItem>
</ProtectedComponent>
<ProtectedComponent requiredAccess="superadmin.plan.delete">
<CustomActionItem
onClick={() => openDelete(row)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 size={16} className="mr-2" /> Delete Plan
</CustomActionItem>
</ProtectedComponent>
</CustomActionMenu>
),
},
],
[openView, openEdit, openDelete]
);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">
Subscription Plans
</h1>
</div>
<ProtectedComponent requiredAccess="superadmin.plan.create">
<Link to="/subscriptions/add">
<CustomButton variant="primary">+ Add Plan</CustomButton>
</Link>
</ProtectedComponent>
</div>
{isLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
<CustomLoader />
</div>
) : errorMessage ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
{errorMessage}
</div>
) : (
<CustomTable
data={plans}
columns={columns}
getRowId={(row) => row.id}
manualPagination
manualFiltering
totalRows={totalRows}
page={page}
pageSize={pageSize}
search={search}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onSearchChange={setSearch}
searchInputRef={searchInputRef}
/>
)}
{/* View Modal */}
<CustomModal
isOpen={isViewOpen}
onClose={closeView}
title="Plan Details"
size="lg"
footer={
<CustomButton type="button" variant="outlined" onClick={closeView}>
Close
</CustomButton>
}
>
{selectedPlan ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Name
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.name}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Price
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatPrice(selectedPlan.price)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Status
</p>
<CustomStatus
status={
selectedPlan.status === "active" ? "Active" : "Inactive"
}
/>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Visibility
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.is_public ? "Public" : "Private"}
</p>
</div>
{selectedPlan.description && (
<div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Description
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.description}
</p>
</div>
)}
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Created
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedPlan.created_at)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Updated
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{formatDate(selectedPlan.updated_at)}
</p>
</div>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">
Plan Permissions
</p>
{isDetailLoading ? (
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3">
<CustomLoader />
</div>
) : (
<GroupedAccessViewer accesses={viewAccesses} />
)}
</div>
</div>
) : (
<p className="text-sm text-[var(--text-secondary)]">
No plan selected.
</p>
)}
</CustomModal>
{/* Edit Modal */}
<CustomModal
isOpen={isEditOpen}
onClose={closeEdit}
title="Edit Subscription Plan"
size="lg"
footer={
<>
<CustomButton
type="button"
variant="outlined"
onClick={closeEdit}
disabled={isSaving}
>
Cancel
</CustomButton>
<CustomButton
type="submit"
form="edit-plan-form"
variant="primary"
loading={isSaving}
>
Save Changes
</CustomButton>
</>
}
>
<form
id="edit-plan-form"
onSubmit={handleUpdate}
className="space-y-6"
>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomInput
label="Plan Name"
name="name"
placeholder="Enter plan name"
value={editForm.name}
onChange={handleEditChange}
required
/>
<CustomInput
label="Price"
name="price"
type="number"
placeholder="0.00"
value={editForm.price ?? ""}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
price: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
</div>
<CustomInput
label="Description"
name="description"
placeholder="Enter plan description"
value={editForm.description}
onChange={handleEditChange}
/>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<CustomDropdown
label="Status"
name="status"
value={editForm.status}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, status: e.target.value }))
}
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]}
/>
<div className="flex items-end pb-1">
<CustomCheckBox
label="Publicly Visible"
name="is_public"
checked={editForm.is_public}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
is_public: e.target.checked,
}))
}
/>
</div>
</div>
<div className="border-t border-gray-200 pt-6">
<GroupedAccessSelector
allAccesses={allAccesses}
selectedIds={editAccessIds}
onChange={setEditAccessIds}
isLoading={isAccessLoading}
title="Plan Permissions"
/>
</div>
{editError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{editError}
</div>
)}
</form>
</CustomModal>
{/* Delete Modal */}
<CustomConfirmationModal
isOpen={isDeleteOpen}
onClose={closeDelete}
onConfirm={handleDelete}
title="Delete subscription plan?"
description={
deleteError || "This plan will be permanently removed."
}
confirmText="Delete Plan"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default AllSubscriptions;
+13
View File
@@ -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 (
<Routes>
<Route index element={<AllSubscriptions />} />
<Route path="add" element={<AddSubscriptions />} />
</Routes>
);
};
export default SubscriptionsRoutes;
+5 -4
View File
@@ -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 = {
+208 -153
View File
@@ -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<TenantCreateRequest>({
tenant_name: "",
tenant_domain: "",
tenant_logo_url: "",
modules: []
});
const [tenantName, setTenantName] = useState("");
const [tenantDomain, setTenantDomain] = useState("");
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
const [selectedPlanId, setSelectedPlanId] = useState("");
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [allAccesses, setAllAccesses] = useState<RoleAccess[]>([]);
const [allModules, setAllModules] = useState<Module[]>([]);
const [availableModules, setAvailableModules] = useState<Module[]>([]);
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
const [planModules, setPlanModules] = useState<{ module_id: string; module_name: string }[]>([]);
const [isPlanModulesLoading, setIsPlanModulesLoading] = useState(false);
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(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<HTMLInputElement>) => {
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<string>();
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 = () => {
</h1>
<p className="text-sm text-gray-500">
{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."}
</p>
</div>
@@ -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
/>
<CustomInput
label="Tenant Domain"
name="tenant_domain"
placeholder="example.com"
value={formData.tenant_domain}
onChange={handleChange}
value={tenantDomain}
onChange={(e) => setTenantDomain(e.target.value)}
required
/>
</div>
@@ -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)}
/>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Module Provisioning</h3>
{isModulesLoading ? (
<div className="text-sm text-gray-500">Loading modules...</div>
) : availableModules.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<AlertCircle size={16} />
No modules available for provisioning.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{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 (
<div key={module.id} className={`p-4 rounded-lg border ${isSelected ? 'border-primary-200 bg-primary-50' : 'border-gray-200 bg-gray-50'}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<input
type="checkbox"
id={`module-${module.id}`}
checked={isSelected}
onChange={() => handleModuleToggle(module.id)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
/>
<label htmlFor={`module-${module.id}`} className="cursor-pointer">
<div className="font-medium text-gray-900">{module.module_name}</div>
<div className="text-xs text-gray-500">ID: {module.module_id}</div>
</label>
</div>
{isSelected && (
<div className="w-48">
<CustomDropdown
label=""
value={selectedConfig?.environment_slug || ''}
onChange={(e) => 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 && (
<div className="text-xs text-red-500 mt-1">No environments found</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
<CustomDropdown
label="Select Plan"
name="plan_id"
value={selectedPlanId}
onChange={(e) => 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
/>
</div>
{/* Module Environment Assignment — appears after plan selection */}
{selectedPlanId && (
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-1">Module Environments</h3>
<p className="text-sm text-gray-500 mb-4">
Select the environment for each module included in this plan.
</p>
{isPlanModulesLoading ? (
<div className="text-sm text-gray-500">Resolving plan modules...</div>
) : planModules.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<AlertCircle size={16} />
This plan has no module-level accesses configured.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{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 (
<div
key={mod.module_id}
className="p-4 rounded-lg border border-primary-200 bg-primary-50"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div>
<div className="font-medium text-gray-900">{mod.module_name}</div>
</div>
</div>
<div className="w-48">
<CustomDropdown
label=""
value={assignment?.environment_slug || ""}
onChange={(e) => 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 && (
<div className="text-xs text-red-500 mt-1">No environments found</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{errorMessage && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
{errorMessage}
+75 -164
View File
@@ -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<T>(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 = () => {
@@ -95,15 +95,12 @@ const AllTenants = () => {
tenant_domain: "",
tenant_logo_url: "",
is_active: true,
modules: []
plan_id: "",
});
// Module Management State
const { listModules, listEnvironments, listTenantModules } = useModuleApi();
const [availableModules, setAvailableModules] = useState<Module[]>([]);
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
const [isLoadingModules, setIsLoadingModules] = useState(false);
// Subscription plans for dropdown
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
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 = () => {
<span className="text-(--text-secondary)"></span>
),
},
{
key: "plan_id",
header: "Plan",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{getPlanName(row.plan_id)}
</span>
),
},
{
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"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Subscription Plan
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{getPlanName(selectedTenant.plan_id)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Created
@@ -678,63 +631,21 @@ const AllTenants = () => {
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Module Provisioning</h3>
{isLoadingModules ? (
<div className="text-sm text-gray-500">Loading modules...</div>
) : availableModules.length === 0 ? (
<div className="text-sm text-gray-500 flex items-center gap-2">
<AlertCircle size={16} />
No modules available for provisioning.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{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 (
<div key={module.id} className={`p-4 rounded-lg border ${isSelected ? 'border-primary-200 bg-primary-50' : 'border-gray-200 bg-gray-50'}`}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<input
type="checkbox"
id={`edit-module-${module.id}`}
checked={isSelected}
onChange={() => handleModuleToggle(module.id)}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
/>
<label htmlFor={`edit-module-${module.id}`} className="cursor-pointer">
<div className="font-medium text-gray-900">{module.module_name}</div>
<div className="text-xs text-gray-500">ID: {module.module_id}</div>
</label>
</div>
{isSelected && (
<div className="w-48">
<CustomDropdown
label=""
value={selectedConfig?.environment_slug || ''}
onChange={(e) => 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 && (
<div className="text-xs text-red-500 mt-1">No environments found</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
)}
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
<CustomDropdown
label="Select Plan"
name="plan_id"
value={editForm.plan_id}
onChange={(e) =>
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}
/>
</div>
{editError && (
+7 -1
View File
@@ -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: <CreditCard size={22} />,
name: "Subscriptions",
path: "/subscriptions",
access: "superadmin.plan.read",
},
{
icon: <UserCog size={22} />,
name: "Roles",
+2
View File
@@ -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 = () => {
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/tenants/*" element={<Tenants />} />
<Route path="/subscriptions/*" element={<Subscriptions />} />
<Route path="/roles/*" element={<Roles />} />
<Route path="/theme/*" element={<Theme />} />
<Route path="/settings" element={<SettingsPage />} />