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>
253 lines
9.3 KiB
TypeScript
253 lines
9.3 KiB
TypeScript
/**
|
|
* FeaturePicker Component
|
|
*
|
|
* A searchable picker for selecting features to include in a plan or version.
|
|
* Features are grouped by type (boolean capabilities vs integer limits).
|
|
* Features are loaded dynamically from the billing API.
|
|
*/
|
|
|
|
import React, { useState, useMemo } from 'react';
|
|
import { Check, Sliders, Search, X } from 'lucide-react';
|
|
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
|
|
|
|
export interface FeaturePickerProps {
|
|
/** Available features from the API */
|
|
features: Feature[];
|
|
/** Currently selected features with their values */
|
|
selectedFeatures: PlanFeatureWrite[];
|
|
/** Callback when selection changes */
|
|
onChange: (features: PlanFeatureWrite[]) => void;
|
|
/** Optional: Show compact view */
|
|
compact?: boolean;
|
|
}
|
|
|
|
export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
|
features,
|
|
selectedFeatures,
|
|
onChange,
|
|
compact = false,
|
|
}) => {
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
|
|
// Group features by type
|
|
const { booleanFeatures, integerFeatures } = useMemo(() => {
|
|
const boolean = features.filter((f) => f.feature_type === 'boolean');
|
|
const integer = features.filter((f) => f.feature_type === 'integer');
|
|
return { booleanFeatures: boolean, integerFeatures: integer };
|
|
}, [features]);
|
|
|
|
// Filter by search term
|
|
const filteredBooleanFeatures = useMemo(() => {
|
|
if (!searchTerm) return booleanFeatures;
|
|
const term = searchTerm.toLowerCase();
|
|
return booleanFeatures.filter(
|
|
(f) =>
|
|
f.name.toLowerCase().includes(term) ||
|
|
f.code.toLowerCase().includes(term) ||
|
|
f.description?.toLowerCase().includes(term)
|
|
);
|
|
}, [booleanFeatures, searchTerm]);
|
|
|
|
const filteredIntegerFeatures = useMemo(() => {
|
|
if (!searchTerm) return integerFeatures;
|
|
const term = searchTerm.toLowerCase();
|
|
return integerFeatures.filter(
|
|
(f) =>
|
|
f.name.toLowerCase().includes(term) ||
|
|
f.code.toLowerCase().includes(term) ||
|
|
f.description?.toLowerCase().includes(term)
|
|
);
|
|
}, [integerFeatures, searchTerm]);
|
|
|
|
const hasNoResults =
|
|
searchTerm && filteredBooleanFeatures.length === 0 && filteredIntegerFeatures.length === 0;
|
|
|
|
// Check if a feature is selected
|
|
const isSelected = (code: string): boolean => {
|
|
return selectedFeatures.some((f) => f.feature_code === code);
|
|
};
|
|
|
|
// Get selected feature data
|
|
const getSelectedFeature = (code: string): PlanFeatureWrite | undefined => {
|
|
return selectedFeatures.find((f) => f.feature_code === code);
|
|
};
|
|
|
|
// Toggle boolean feature selection
|
|
const toggleBooleanFeature = (code: string) => {
|
|
if (isSelected(code)) {
|
|
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
|
} else {
|
|
onChange([
|
|
...selectedFeatures,
|
|
{ feature_code: code, bool_value: true, int_value: null },
|
|
]);
|
|
}
|
|
};
|
|
|
|
// Toggle integer feature selection
|
|
const toggleIntegerFeature = (code: string) => {
|
|
if (isSelected(code)) {
|
|
onChange(selectedFeatures.filter((f) => f.feature_code !== code));
|
|
} else {
|
|
onChange([
|
|
...selectedFeatures,
|
|
{ feature_code: code, bool_value: null, int_value: 0 },
|
|
]);
|
|
}
|
|
};
|
|
|
|
// Update integer feature value
|
|
const updateIntegerValue = (code: string, value: number) => {
|
|
onChange(
|
|
selectedFeatures.map((f) =>
|
|
f.feature_code === code ? { ...f, int_value: value } : f
|
|
)
|
|
);
|
|
};
|
|
|
|
const clearSearch = () => {
|
|
setSearchTerm('');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Search Box */}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
placeholder="Search features..."
|
|
className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
|
/>
|
|
{searchTerm && (
|
|
<button
|
|
type="button"
|
|
onClick={clearSearch}
|
|
aria-label="Clear search"
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* No Results Message */}
|
|
{hasNoResults && (
|
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
|
<Search className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
<p>No features found matching "{searchTerm}"</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Boolean Features (Capabilities) */}
|
|
{filteredBooleanFeatures.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
|
<Check className="w-4 h-4" /> Capabilities
|
|
</h4>
|
|
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-2`}>
|
|
{filteredBooleanFeatures.map((feature) => {
|
|
const selected = isSelected(feature.code);
|
|
|
|
return (
|
|
<label
|
|
key={feature.id}
|
|
className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors ${
|
|
selected
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={() => toggleBooleanFeature(feature.code)}
|
|
aria-label={feature.name}
|
|
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<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}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Integer Features (Limits & Quotas) */}
|
|
{filteredIntegerFeatures.length > 0 && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
|
<Sliders className="w-4 h-4" /> Limits & Quotas
|
|
</h4>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
Set to 0 for unlimited. Uncheck to exclude from plan.
|
|
</p>
|
|
<div className={`grid ${compact ? 'grid-cols-1' : 'grid-cols-2'} gap-3`}>
|
|
{filteredIntegerFeatures.map((feature) => {
|
|
const selectedFeature = getSelectedFeature(feature.code);
|
|
const selected = !!selectedFeature;
|
|
|
|
return (
|
|
<div
|
|
key={feature.id}
|
|
className={`flex items-center gap-3 p-3 border rounded-lg ${
|
|
selected
|
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
|
: 'border-gray-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={() => toggleIntegerFeature(feature.code)}
|
|
aria-label={feature.name}
|
|
className="rounded border-gray-300 dark:border-gray-600"
|
|
/>
|
|
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
|
{feature.name}
|
|
</span>
|
|
</label>
|
|
{selected && (
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={selectedFeature?.int_value || 0}
|
|
onChange={(e) =>
|
|
updateIntegerValue(feature.code, parseInt(e.target.value) || 0)
|
|
}
|
|
aria-label={`${feature.name} limit value`}
|
|
className="w-20 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
placeholder="0"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{!searchTerm && features.length === 0 && (
|
|
<div className="p-8 text-center text-gray-500 dark:text-gray-400 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
|
<Sliders className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
|
<p>No features defined yet.</p>
|
|
<p className="text-xs mt-1">Add features in the Features Library tab first.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|