feat: tenant-subscription

fix: deleted updatemodule
This commit is contained in:
amee
2026-04-18 16:31:52 +05:30
parent 10c53e7d50
commit 51bac652df
8 changed files with 258 additions and 46 deletions
@@ -12,7 +12,7 @@ import CustomButton from '../../../../components/custom/CustomButton';
const ModuleList = () => {
const navigate = useNavigate();
const { t } = useTranslation(['modules', 'common']);
const { listModules, deleteModule, updateModule, loading } = useModuleApi();
const { listModules, deleteModule, loading } = useModuleApi();
const [modules, setModules] = useState<Module[]>([]);
const [showForm, setShowForm] = useState(false);
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
@@ -3,6 +3,7 @@ export type SubscriptionPlan = {
name: string;
description?: string | null;
price?: number | null;
duration_days?: number | null;
is_public: boolean;
status: string;
created_at: string;
@@ -18,6 +19,7 @@ export type SubscriptionPlanCreateRequest = {
name: string;
description?: string;
price?: number;
duration_days?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
@@ -28,6 +30,7 @@ export type SubscriptionPlanUpdateRequest = {
name?: string;
description?: string;
price?: number;
duration_days?: number;
is_public?: boolean;
status?: string;
access_ids?: string[];
@@ -19,6 +19,7 @@ const AddSubscriptions = () => {
name: "",
description: "",
price: undefined,
duration_days: undefined,
is_public: true,
status: "active",
access_ids: [],
@@ -96,6 +97,7 @@ const AddSubscriptions = () => {
name: formData.name.trim(),
description: formData.description?.trim() || undefined,
price: formData.price ? Number(formData.price) : undefined,
duration_days: formData.duration_days ? Number(formData.duration_days) : undefined,
is_public: formData.is_public,
status: formData.status,
access_ids: accessIds,
@@ -157,6 +159,19 @@ const AddSubscriptions = () => {
}))
}
/>
<CustomInput
label="Duration (Days)"
name="duration_days"
type="number"
placeholder="30"
value={formData.duration_days ?? ""}
onChange={(e) =>
setFormData((prev) => ({
...prev,
duration_days: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
</div>
<CustomInput
@@ -43,6 +43,7 @@ interface EditFormState {
name: string;
description: string;
price: number | undefined;
duration_days: number | undefined;
is_public: boolean;
status: string;
}
@@ -88,6 +89,7 @@ const AllSubscriptions = () => {
name: "",
description: "",
price: undefined,
duration_days: undefined,
is_public: true,
status: "active",
});
@@ -249,6 +251,7 @@ const AllSubscriptions = () => {
name: plan.name,
description: plan.description ?? "",
price: plan.price ?? undefined,
duration_days: plan.duration_days ?? undefined,
is_public: plan.is_public,
status: plan.status,
});
@@ -311,6 +314,7 @@ const AllSubscriptions = () => {
name: editForm.name.trim(),
description: editForm.description.trim() || undefined,
price: editForm.price ? Number(editForm.price) : undefined,
duration_days: editForm.duration_days ? Number(editForm.duration_days) : undefined,
is_public: editForm.is_public,
status: editForm.status,
access_ids: accessIds,
@@ -381,6 +385,16 @@ const AllSubscriptions = () => {
</span>
),
},
{
key: "duration_days",
header: "Duration",
searchable: false,
render: (row) => (
<span className="text-sm font-medium text-[var(--text-primary)]">
{row.duration_days ? `${row.duration_days} days` : "-"}
</span>
),
},
{
key: "status",
header: (
@@ -570,6 +584,14 @@ const AllSubscriptions = () => {
{formatPrice(selectedPlan.price)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Duration
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedPlan.duration_days ? `${selectedPlan.duration_days} days` : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Status
@@ -689,6 +711,19 @@ const AllSubscriptions = () => {
}))
}
/>
<CustomInput
label="Duration (Days)"
name="duration_days"
type="number"
placeholder="30"
value={editForm.duration_days ?? ""}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
duration_days: e.target.value ? Number(e.target.value) : undefined,
}))
}
/>
</div>
<CustomInput
+12
View File
@@ -1,10 +1,16 @@
export type TenantStatus = "ACTIVE" | "INACTIVE" | "EXPIRED";
export type Tenant = {
id: string;
tenant_id: string;
tenant_name: string;
tenant_domain: string;
tenant_logo_url?: string | null;
is_active: boolean;
plan_id?: string | null;
start_date?: string | null;
end_date?: string | null;
status: TenantStatus;
created_at: string;
updated_at: string;
};
@@ -19,6 +25,9 @@ export type TenantCreateRequest = {
tenant_domain: string;
tenant_logo_url?: string;
plan_id: string;
start_date?: string;
end_date?: string;
status?: TenantStatus;
module_environments?: ModuleEnvironmentAssignment[];
};
@@ -28,6 +37,9 @@ export type TenantUpdateRequest = {
tenant_logo_url?: string | null;
is_active?: boolean;
plan_id?: string;
start_date?: string | null;
end_date?: string | null;
status?: TenantStatus;
};
export type TenantPaginatedResponse = {
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CustomButton, CustomInput, CustomDropdown } from "../../../components/custom";
import { CustomButton, CustomDatePicker, CustomInput, CustomDropdown } from "../../../components/custom";
import CustomBackButton from "../../../components/custom/CustomBackButton";
import { CustomLoader } from "../../../components/custom";
import { tenantsApi } from "../TenantsApi";
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment } from "../TenantsTypes";
import type { Tenant, TenantCreateRequest, ModuleEnvironmentAssignment, TenantStatus } from "../TenantsTypes";
import { useAuth } from "../../../context/AuthContext";
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
@@ -13,7 +13,17 @@ import { adminModuleApi } from "../../modules/admin/AdminModuleApi";
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
import { AlertCircle } from "lucide-react";
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
const addDays = (dateValue: string, days?: number | null) => {
if (!dateValue || !days) return "";
const nextDate = new Date(`${dateValue}T00:00:00`);
nextDate.setDate(nextDate.getDate() + days);
return toIsoDate(nextDate);
};
const AddTenants = () => {
const today = new Date().toISOString().slice(0, 10);
const { hasAccess, isLoading: isAuthLoading } = useAuth();
const canCreateTenant = hasAccess("superadmin.tenant.create");
const navigate = useNavigate();
@@ -22,6 +32,9 @@ const AddTenants = () => {
const [tenantDomain, setTenantDomain] = useState("");
const [tenantLogoUrl, setTenantLogoUrl] = useState("");
const [selectedPlanId, setSelectedPlanId] = useState("");
const [startDate, setStartDate] = useState(today);
const [endDate, setEndDate] = useState("");
const [tenantStatus, setTenantStatus] = useState<TenantStatus>("ACTIVE");
const [moduleEnvAssignments, setModuleEnvAssignments] = useState<ModuleEnvironmentAssignment[]>([]);
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
@@ -43,6 +56,15 @@ const AddTenants = () => {
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
useEffect(() => {
if (!selectedPlanId) return;
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
if (!selectedPlan) return;
setStartDate((prev) => prev || today);
setEndDate(addDays(startDate || today, selectedPlan.duration_days));
}, [plans, selectedPlanId, startDate, today]);
useEffect(() => {
if (isAuthLoading || canCreateTenant) {
setIsTenantLoading(false);
@@ -195,6 +217,9 @@ const AddTenants = () => {
tenant_domain: tenantDomain.trim(),
tenant_logo_url: tenantLogoUrl.trim() || undefined,
plan_id: selectedPlanId,
start_date: startDate || undefined,
end_date: endDate || undefined,
status: tenantStatus,
module_environments: moduleEnvAssignments.filter((a) => a.environment_slug),
};
@@ -313,6 +338,35 @@ const AddTenants = () => {
/>
</div>
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
<CustomDatePicker
label="Start Date"
value={startDate}
onChange={(e) => {
const nextStartDate = e.target.value;
setStartDate(nextStartDate);
const selectedPlan = plans.find((plan) => plan.id === selectedPlanId);
setEndDate(addDays(nextStartDate, selectedPlan?.duration_days));
}}
/>
<CustomDatePicker
label="End Date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate || undefined}
/>
<CustomDropdown
label="Status"
value={tenantStatus}
onChange={(e) => setTenantStatus(e.target.value as TenantStatus)}
options={[
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Expired", value: "EXPIRED" },
]}
/>
</div>
{/* Module Environment Assignment — appears after plan selection */}
{selectedPlanId && (
<div className="border-t border-gray-200 pt-6">
+124 -15
View File
@@ -6,6 +6,7 @@ import {
CustomCheckBox,
CustomColumnFilter,
CustomConfirmationModal,
CustomDatePicker,
CustomDropdown,
CustomInput,
CustomModal,
@@ -22,12 +23,21 @@ import {
} from "../../../components/custom/CustomColumnFilter.utils";
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
import { formatDate } from "../../../lib/dateFormat";
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
import type { Tenant, TenantStatus, TenantUpdateRequest } from "../TenantsTypes";
import { tenantsApi } from "../TenantsApi";
import { useAuth } from "../../../context/AuthContext";
import { subscriptionsApi } from "../../subscriptions/SubscriptionsApi";
import type { SubscriptionPlan } from "../../subscriptions/SubscriptionTypes";
const toIsoDate = (value: Date) => value.toISOString().slice(0, 10);
const addDays = (dateValue: string, days?: number | null) => {
if (!dateValue || !days) return "";
const nextDate = new Date(`${dateValue}T00:00:00`);
nextDate.setDate(nextDate.getDate() + days);
return toIsoDate(nextDate);
};
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
@@ -58,6 +68,9 @@ interface EditFormState {
tenant_logo_url: string;
is_active: boolean;
plan_id: string;
start_date: string;
end_date: string;
status: TenantStatus;
}
const AllTenants = () => {
@@ -77,11 +90,13 @@ const AllTenants = () => {
tenant_logo_url: "",
is_active: true,
plan_id: "",
start_date: "",
end_date: "",
status: "ACTIVE",
});
// Subscription plans for dropdown
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [isPlansLoading, setIsPlansLoading] = useState(false);
const [editError, setEditError] = useState("");
const [deleteError, setDeleteError] = useState("");
@@ -224,14 +239,11 @@ const AllTenants = () => {
let isMounted = true;
const loadPlans = async () => {
setIsPlansLoading(true);
try {
const data = await subscriptionsApi.getAll();
if (isMounted) setPlans(data);
} catch (error) {
console.error("Failed to load subscription plans:", error);
} finally {
if (isMounted) setIsPlansLoading(false);
}
};
@@ -298,6 +310,9 @@ const AllTenants = () => {
tenant_logo_url: tenant.tenant_logo_url ?? "",
is_active: tenant.is_active,
plan_id: tenant.plan_id ?? "",
start_date: tenant.start_date ?? "",
end_date: tenant.end_date ?? "",
status: tenant.status,
});
}, []);
@@ -355,6 +370,9 @@ const AllTenants = () => {
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
is_active: editForm.is_active,
plan_id: editForm.plan_id || undefined,
start_date: editForm.start_date || null,
end_date: editForm.end_date || null,
status: editForm.status,
};
const updatedTenant = await tenantsApi.update(tenantId, payload);
@@ -520,7 +538,27 @@ const AllTenants = () => {
),
searchable: false,
render: (row) => (
<CustomStatus status={row.is_active ? "Active" : "Inactive"} />
<CustomStatus status={row.status} />
),
},
{
key: "start_date",
header: "Start Date",
searchable: false,
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{row.start_date ? formatDate(row.start_date) : "-"}
</div>
),
},
{
key: "end_date",
header: "End Date",
searchable: false,
render: (row) => (
<div className="text-sm font-medium text-(--text-primary)">
{row.end_date ? formatDate(row.end_date) : "-"}
</div>
),
},
{
@@ -669,13 +707,19 @@ const AllTenants = () => {
<p className="text-sm text-[var(--text-secondary)]"></p>
)}
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Tenant ID
</p>
<p className="text-sm font-medium text-[var(--text-primary)] break-all">
{selectedTenant.tenant_id}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Status
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.is_active ? "Active" : "Inactive"}
</p>
<CustomStatus status={selectedTenant.status} />
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
@@ -685,6 +729,22 @@ const AllTenants = () => {
{getPlanName(selectedTenant.plan_id)}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Start Date
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.start_date ? formatDate(selectedTenant.start_date) : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
End Date
</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{selectedTenant.end_date ? formatDate(selectedTenant.end_date) : "-"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">
Created
@@ -780,15 +840,64 @@ const AllTenants = () => {
label="Select Plan"
name="plan_id"
value={editForm.plan_id}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, plan_id: e.target.value }))
}
onChange={(e) => {
const nextPlanId = e.target.value;
const selectedPlan = plans.find((plan) => plan.id === nextPlanId);
const nextStartDate = editForm.start_date || toIsoDate(new Date());
setEditForm((prev) => ({
...prev,
plan_id: nextPlanId,
start_date: nextStartDate,
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
}));
}}
options={plans.map((plan) => ({
label: `${plan.name}${plan.price != null ? `${new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(plan.price)}` : ""}`,
label: plan.duration_days
? `${plan.name} (${plan.duration_days} days)`
: plan.name,
value: plan.id,
}))}
placeholder={isPlansLoading ? "Loading plans..." : "Select a subscription plan"}
disabled={isPlansLoading}
placeholder="Select a subscription plan"
/>
</div>
<div className="grid grid-cols-1 gap-6 border-t border-gray-200 pt-6 md:grid-cols-3">
<CustomDatePicker
label="Start Date"
value={editForm.start_date}
onChange={(e) => {
const nextStartDate = e.target.value;
const selectedPlan = plans.find((plan) => plan.id === editForm.plan_id);
setEditForm((prev) => ({
...prev,
start_date: nextStartDate,
end_date: addDays(nextStartDate, selectedPlan?.duration_days),
}));
}}
/>
<CustomDatePicker
label="End Date"
value={editForm.end_date}
min={editForm.start_date || undefined}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, end_date: e.target.value }))
}
/>
<CustomDropdown
label="Status"
value={editForm.status}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
status: e.target.value as TenantStatus,
}))
}
options={[
{ label: "Active", value: "ACTIVE" },
{ label: "Inactive", value: "INACTIVE" },
{ label: "Expired", value: "EXPIRED" },
]}
/>
</div>
+12 -28
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
@@ -146,6 +146,16 @@ const AppSidebar: React.FC = () => {
[location.pathname]
);
const isSubmenuRouteActive = useCallback(
(submenu?: SubMenuItem[]) =>
submenu?.some(
(subItem) =>
location.pathname === subItem.path ||
location.pathname.startsWith(subItem.path + "/")
) ?? false,
[location.pathname]
);
const visibleNavItems = useMemo(
() => navItems.filter((item) => {
if (!item.access) return true;
@@ -161,32 +171,6 @@ const AppSidebar: React.FC = () => {
const [openSubmenus, setOpenSubmenus] = useState<Record<string, boolean>>({});
useEffect(() => {
const currentPath = location.pathname;
setOpenSubmenus(prev => {
const newState = { ...prev };
Object.keys(newState).forEach(menuName => {
if (newState[menuName]) {
const navItem = navItems.find(item => item.name === menuName);
if (navItem && navItem.submenu) {
const isMatchingSubmenu = navItem.submenu.some(subItem =>
currentPath === subItem.path || currentPath.startsWith(subItem.path + "/")
);
if (!isMatchingSubmenu) {
newState[menuName] = false;
}
}
}
});
return newState;
});
}, [location.pathname]);
const toggleSubmenu = (name: string) => {
if (!isExpanded && !isMobile) {
toggleSidebar(); // Auto expand when clicking submenu in collapsed state
@@ -288,7 +272,7 @@ const AppSidebar: React.FC = () => {
{visibleNavItems.map((nav) => {
const active = isActive(nav.path);
const hasSubmenu = nav.submenu && nav.submenu.length > 0;
const isMenuOpen = openSubmenus[nav.name];
const isMenuOpen = openSubmenus[nav.name] || isSubmenuRouteActive(nav.submenu);
if (hasSubmenu) {
return (