Add TenantCustomTier system and fix BusinessEditModal feature loading

Backend:
- Add TenantCustomTier model for per-tenant feature overrides
- Update EntitlementService to check custom tier before plan features
- Add custom_tier action on TenantViewSet (GET/PUT/DELETE)
- Add Celery task for grace period management (30-day expiry)

Frontend:
- Add DynamicFeaturesEditor component for dynamic feature management
- Fix BusinessEditModal to load features from plan defaults when no custom tier
- Update limits (max_users, max_resources, etc.) to use featureValues
- Remove outdated canonical feature check from FeaturePicker (removes warning icons)
- Add useBillingPlans hook for accessing billing system data
- Add custom tier API functions to platform.ts

Features now follow consistent rules:
- Load from plan defaults when no custom tier exists
- Load from custom tier when one exists
- Reset to plan defaults when plan changes
- Save to custom tier on edit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-12 21:00:54 -05:00
parent d25c578e59
commit b384d9912a
183 changed files with 47627 additions and 3955 deletions

View File

@@ -3,12 +3,11 @@
*
* A searchable picker for selecting features to include in a plan or version.
* Features are grouped by type (boolean capabilities vs integer limits).
* Non-canonical features (not in the catalog) are flagged with a warning.
* Features are loaded dynamically from the billing API.
*/
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X, AlertTriangle } from 'lucide-react';
import { isCanonicalFeature } from '../featureCatalog';
import { Check, Sliders, Search, X } from 'lucide-react';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
export interface FeaturePickerProps {
@@ -151,7 +150,6 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
{filteredBooleanFeatures.map((feature) => {
const selected = isSelected(feature.code);
const isCanonical = isCanonicalFeature(feature.code);
return (
<label
@@ -170,16 +168,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{feature.name}
</span>
{!isCanonical && (
<span title="Not in canonical catalog">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500" />
</span>
)}
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{feature.name}
</span>
{feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
{feature.description}
@@ -206,7 +197,6 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
{filteredIntegerFeatures.map((feature) => {
const selectedFeature = getSelectedFeature(feature.code);
const selected = !!selectedFeature;
const isCanonical = isCanonicalFeature(feature.code);
return (
<div
@@ -225,18 +215,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
aria-label={feature.name}
className="rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">
{feature.name}
</span>
{!isCanonical && (
<span title="Not in canonical catalog">
<AlertTriangle className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
</span>
)}
</div>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
{feature.name}
</span>
</label>
{selected && (
<input