- Update Service model to use price_cents/deposit_amount_cents as IntegerField - Add @property methods for backward compatibility (price, deposit_amount return dollars) - Update ServiceSerializer to convert dollars <-> cents on read/write - Add migration to convert column types from numeric to integer - Fix BusinessEditModal to properly use typed PlatformBusiness interface - Add missing feature permission fields to PlatformBusiness TypeScript interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
17 KiB
TypeScript
417 lines
17 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { X, Save, RefreshCw } from 'lucide-react';
|
|
import { useUpdateBusiness } from '../../../hooks/usePlatform';
|
|
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
|
|
import { PlatformBusiness } from '../../../api/platform';
|
|
import FeaturesPermissionsEditor, { PERMISSION_DEFINITIONS, getPermissionKey } from '../../../components/platform/FeaturesPermissionsEditor';
|
|
|
|
// Default tier settings - used when no subscription plans are loaded
|
|
const TIER_DEFAULTS: Record<string, {
|
|
max_users: number;
|
|
max_resources: number;
|
|
can_manage_oauth_credentials: boolean;
|
|
can_accept_payments: boolean;
|
|
can_use_custom_domain: boolean;
|
|
can_white_label: boolean;
|
|
can_api_access: boolean;
|
|
}> = {
|
|
FREE: {
|
|
max_users: 2,
|
|
max_resources: 5,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
},
|
|
STARTER: {
|
|
max_users: 5,
|
|
max_resources: 15,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
},
|
|
PROFESSIONAL: {
|
|
max_users: 15,
|
|
max_resources: 50,
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
can_white_label: false,
|
|
can_api_access: true,
|
|
},
|
|
ENTERPRISE: {
|
|
max_users: -1, // unlimited
|
|
max_resources: -1, // unlimited
|
|
can_manage_oauth_credentials: true,
|
|
can_accept_payments: true,
|
|
can_use_custom_domain: true,
|
|
can_white_label: true,
|
|
can_api_access: true,
|
|
},
|
|
};
|
|
|
|
interface BusinessEditModalProps {
|
|
business: PlatformBusiness | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen, onClose }) => {
|
|
const updateBusinessMutation = useUpdateBusiness();
|
|
const { data: subscriptionPlans } = useSubscriptionPlans();
|
|
|
|
const [editForm, setEditForm] = useState({
|
|
name: '',
|
|
is_active: true,
|
|
subscription_tier: 'FREE',
|
|
// Limits
|
|
max_users: 5,
|
|
max_resources: 10,
|
|
// Platform Permissions (flat, matching backend model)
|
|
can_manage_oauth_credentials: false,
|
|
can_accept_payments: false,
|
|
can_use_custom_domain: false,
|
|
can_white_label: false,
|
|
can_api_access: false,
|
|
// Feature permissions (flat, matching backend model)
|
|
can_add_video_conferencing: false,
|
|
can_connect_to_api: false,
|
|
can_book_repeated_events: true,
|
|
can_require_2fa: false,
|
|
can_download_logs: false,
|
|
can_delete_data: false,
|
|
can_use_sms_reminders: false,
|
|
can_use_masked_phone_numbers: false,
|
|
can_use_pos: false,
|
|
can_use_mobile_app: false,
|
|
can_export_data: false,
|
|
can_use_plugins: true,
|
|
can_use_tasks: true,
|
|
can_create_plugins: false,
|
|
can_use_webhooks: false,
|
|
can_use_calendar_sync: false,
|
|
can_use_contracts: false,
|
|
can_process_refunds: false,
|
|
can_create_packages: false,
|
|
can_use_email_templates: false,
|
|
can_customize_booking_page: false,
|
|
advanced_reporting: false,
|
|
priority_support: false,
|
|
dedicated_support: false,
|
|
sso_enabled: false,
|
|
});
|
|
|
|
// Get tier defaults from subscription plans or fallback to static defaults
|
|
const getTierDefaults = (tier: string) => {
|
|
// Try to find matching subscription plan
|
|
if (subscriptionPlans) {
|
|
const tierNameMap: Record<string, string> = {
|
|
'FREE': 'Free',
|
|
'STARTER': 'Starter',
|
|
'PROFESSIONAL': 'Professional',
|
|
'ENTERPRISE': 'Enterprise',
|
|
};
|
|
const plan = subscriptionPlans.find(p =>
|
|
p.business_tier === tierNameMap[tier] || p.business_tier === tier
|
|
);
|
|
if (plan) {
|
|
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
|
|
return {
|
|
// Limits
|
|
max_users: plan.limits?.max_users ?? staticDefaults.max_users,
|
|
max_resources: plan.limits?.max_resources ?? staticDefaults.max_resources,
|
|
// Platform Permissions
|
|
can_manage_oauth_credentials: plan.permissions?.can_manage_oauth_credentials ?? staticDefaults.can_manage_oauth_credentials,
|
|
can_accept_payments: plan.permissions?.can_accept_payments ?? staticDefaults.can_accept_payments,
|
|
can_use_custom_domain: plan.permissions?.can_use_custom_domain ?? staticDefaults.can_use_custom_domain,
|
|
can_white_label: plan.permissions?.can_white_label ?? staticDefaults.can_white_label,
|
|
can_api_access: plan.permissions?.can_api_access ?? staticDefaults.can_api_access,
|
|
// Feature permissions (flat, matching backend model)
|
|
can_add_video_conferencing: plan.permissions?.video_conferencing ?? false,
|
|
can_connect_to_api: plan.permissions?.can_api_access ?? false,
|
|
can_book_repeated_events: true,
|
|
can_require_2fa: false,
|
|
can_download_logs: false,
|
|
can_delete_data: false,
|
|
can_use_sms_reminders: plan.permissions?.sms_reminders ?? false,
|
|
can_use_masked_phone_numbers: plan.permissions?.masked_calling ?? false,
|
|
can_use_pos: false,
|
|
can_use_mobile_app: false,
|
|
can_export_data: plan.permissions?.export_data ?? false,
|
|
can_use_plugins: plan.permissions?.plugins ?? true,
|
|
can_use_tasks: plan.permissions?.tasks ?? true,
|
|
can_create_plugins: plan.permissions?.can_create_plugins ?? false,
|
|
can_use_webhooks: plan.permissions?.webhooks ?? false,
|
|
can_use_calendar_sync: plan.permissions?.calendar_sync ?? false,
|
|
};
|
|
}
|
|
}
|
|
// Fallback to static defaults
|
|
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
|
|
return {
|
|
...staticDefaults,
|
|
can_add_video_conferencing: false,
|
|
can_connect_to_api: staticDefaults.can_api_access,
|
|
can_book_repeated_events: true,
|
|
can_require_2fa: false,
|
|
can_download_logs: false,
|
|
can_delete_data: false,
|
|
can_use_sms_reminders: false,
|
|
can_use_masked_phone_numbers: false,
|
|
can_use_pos: false,
|
|
can_use_mobile_app: false,
|
|
can_export_data: false,
|
|
can_use_plugins: true,
|
|
can_use_tasks: true,
|
|
can_create_plugins: false,
|
|
can_use_webhooks: false,
|
|
can_use_calendar_sync: false,
|
|
};
|
|
};
|
|
|
|
// Handle subscription tier change - auto-update limits and permissions
|
|
const handleTierChange = (newTier: string) => {
|
|
const defaults = getTierDefaults(newTier);
|
|
setEditForm(prev => ({
|
|
...prev,
|
|
subscription_tier: newTier,
|
|
...defaults,
|
|
}));
|
|
};
|
|
|
|
// Reset to tier defaults button handler
|
|
const handleResetToTierDefaults = () => {
|
|
const defaults = getTierDefaults(editForm.subscription_tier);
|
|
setEditForm(prev => ({
|
|
...prev,
|
|
...defaults,
|
|
}));
|
|
};
|
|
|
|
// Update form when business changes
|
|
useEffect(() => {
|
|
if (business) {
|
|
setEditForm({
|
|
name: business.name,
|
|
is_active: business.is_active,
|
|
subscription_tier: business.tier,
|
|
// Limits
|
|
max_users: business.max_users || 5,
|
|
max_resources: business.max_resources || 10,
|
|
// Platform Permissions (flat, matching backend)
|
|
can_manage_oauth_credentials: business.can_manage_oauth_credentials || false,
|
|
can_accept_payments: business.can_accept_payments || false,
|
|
can_use_custom_domain: business.can_use_custom_domain || false,
|
|
can_white_label: business.can_white_label || false,
|
|
can_api_access: business.can_api_access || false,
|
|
// Feature permissions (flat, matching backend)
|
|
can_add_video_conferencing: business.can_add_video_conferencing || false,
|
|
can_connect_to_api: business.can_connect_to_api || false,
|
|
can_book_repeated_events: business.can_book_repeated_events ?? true,
|
|
can_require_2fa: business.can_require_2fa || false,
|
|
can_download_logs: business.can_download_logs || false,
|
|
can_delete_data: business.can_delete_data || false,
|
|
can_use_sms_reminders: business.can_use_sms_reminders || false,
|
|
can_use_masked_phone_numbers: business.can_use_masked_phone_numbers || false,
|
|
can_use_pos: business.can_use_pos || false,
|
|
can_use_mobile_app: business.can_use_mobile_app || false,
|
|
can_export_data: business.can_export_data || false,
|
|
can_use_plugins: business.can_use_plugins ?? true,
|
|
can_use_tasks: business.can_use_tasks ?? true,
|
|
can_create_plugins: business.can_create_plugins || false,
|
|
can_use_webhooks: business.can_use_webhooks || false,
|
|
can_use_calendar_sync: business.can_use_calendar_sync || false,
|
|
can_use_contracts: business.can_use_contracts || false,
|
|
// Note: These fields are in the form but not yet on the backend model
|
|
// They will be ignored by the backend serializer until added to the Tenant model
|
|
can_process_refunds: false,
|
|
can_create_packages: false,
|
|
can_use_email_templates: false,
|
|
can_customize_booking_page: false,
|
|
advanced_reporting: false,
|
|
priority_support: false,
|
|
dedicated_support: false,
|
|
sso_enabled: false,
|
|
});
|
|
}
|
|
}, [business]);
|
|
|
|
|
|
const handleEditSave = () => {
|
|
if (!business) return;
|
|
|
|
updateBusinessMutation.mutate(
|
|
{
|
|
businessId: business.id,
|
|
data: editForm,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
onClose();
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
if (!isOpen || !business) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Edit Business: {business.name}
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Modal Body */}
|
|
<div className="p-4 space-y-4">
|
|
{/* Business Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Business Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={editForm.name}
|
|
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
|
|
{/* Status */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Active Status
|
|
</label>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Inactive businesses cannot be accessed
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditForm({ ...editForm, is_active: !editForm.is_active })}
|
|
className={`${editForm.is_active ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-500`}
|
|
role="switch"
|
|
>
|
|
<span className={`${editForm.is_active ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Subscription Tier */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Subscription Tier
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={handleResetToTierDefaults}
|
|
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300"
|
|
>
|
|
<RefreshCw size={12} />
|
|
Reset to tier defaults
|
|
</button>
|
|
</div>
|
|
<select
|
|
value={editForm.subscription_tier}
|
|
onChange={(e) => handleTierChange(e.target.value)}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
>
|
|
<option value="FREE">Free Trial</option>
|
|
<option value="STARTER">Starter</option>
|
|
<option value="PROFESSIONAL">Professional</option>
|
|
<option value="ENTERPRISE">Enterprise</option>
|
|
</select>
|
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
Changing tier will auto-update limits and permissions to tier defaults
|
|
</p>
|
|
</div>
|
|
|
|
{/* Limits Configuration */}
|
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
|
|
Limits Configuration
|
|
</h3>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Use -1 for unlimited. These limits control what this business can create.
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Max Users
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="-1"
|
|
value={editForm.max_users}
|
|
onChange={(e) => setEditForm({ ...editForm, max_users: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Max Resources
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="-1"
|
|
value={editForm.max_resources}
|
|
onChange={(e) => setEditForm({ ...editForm, max_resources: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Features & Permissions - Using unified FeaturesPermissionsEditor */}
|
|
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
<FeaturesPermissionsEditor
|
|
mode="business"
|
|
values={Object.fromEntries(
|
|
Object.entries(editForm).filter(([_, v]) => typeof v === 'boolean')
|
|
) as Record<string, boolean>}
|
|
onChange={(key, value) => {
|
|
setEditForm(prev => ({ ...prev, [key]: value }));
|
|
}}
|
|
headerTitle="Features & Permissions"
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Modal Footer */}
|
|
<div className="flex items-center justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium text-sm transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleEditSave}
|
|
disabled={updateBusinessMutation.isPending}
|
|
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<Save size={16} />
|
|
{updateBusinessMutation.isPending ? 'Saving...' : 'Save Changes'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BusinessEditModal;
|