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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
} finally {
|
||||
setLoadingEnvironments(prev => ({ ...prev, [moduleId]: false }));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
const planDetail = await subscriptionsApi.getById(selectedPlanId);
|
||||
|
||||
const handleModuleToggle = async (moduleId: string) => {
|
||||
setFormData(prev => {
|
||||
const currentModules = prev.modules || [];
|
||||
const exists = currentModules.find(m => m.module_id === moduleId);
|
||||
const moduleAccessIdSet = new Set(planDetail.module_access_ids);
|
||||
const moduleIdsFromPlan = new Set<string>();
|
||||
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
modules: currentModules.filter(m => m.module_id !== moduleId)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
modules: [...currentModules, { module_id: moduleId, environment_slug: '' }]
|
||||
};
|
||||
allAccesses.forEach((access) => {
|
||||
if (access.module_id && moduleAccessIdSet.has(access.id)) {
|
||||
moduleIdsFromPlan.add(access.module_id);
|
||||
}
|
||||
});
|
||||
|
||||
const currentModules = formData.modules || [];
|
||||
const isAdding = !currentModules.find(m => m.module_id === moduleId);
|
||||
const moduleMap = new Map(allModules.map((m) => [m.id, m]));
|
||||
const resolvedModules: { module_id: string; module_name: string }[] = [];
|
||||
|
||||
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
|
||||
)
|
||||
}));
|
||||
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 {
|
||||
if (isMounted) setIsPlanModulesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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,62 +292,76 @@ 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 ? (
|
||||
<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} />
|
||||
No modules available for provisioning.
|
||||
This plan has no module-level accesses configured.
|
||||
</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];
|
||||
{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={module.id} className={`p-4 rounded-lg border ${isSelected ? 'border-primary-200 bg-primary-50' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<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">
|
||||
<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>
|
||||
<div className="font-medium text-gray-900">{mod.module_name}</div>
|
||||
</div>
|
||||
</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
|
||||
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={isLoadingEnvs ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnvs || moduleEnvs.length === 0}
|
||||
placeholder={isLoadingEnv ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnv || envs.length === 0}
|
||||
/>
|
||||
{moduleEnvs.length === 0 && !isLoadingEnvs && (
|
||||
{envs.length === 0 && !isLoadingEnv && (
|
||||
<div className="text-xs text-red-500 mt-1">No environments found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -315,6 +369,7 @@ const AddTenants = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
|
||||
@@ -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);
|
||||
plan_id: tenant.plan_id ?? "",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load assigned modules", e);
|
||||
} finally {
|
||||
setIsLoadingModules(false);
|
||||
}
|
||||
}, [listTenantModules]);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Subscription Plan</h3>
|
||||
<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
|
||||
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={isLoadingEnvs ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnvs || moduleEnvs.length === 0}
|
||||
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
|
||||
disabled={isPlansLoading}
|
||||
/>
|
||||
{moduleEnvs.length === 0 && !isLoadingEnvs && (
|
||||
<div className="text-xs text-red-500 mt-1">No environments found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
Reference in New Issue
Block a user