Files
smoothschedule/frontend/src/pages/platform/components/BusinessEditModal.tsx
poduck 18c9a69d75 fix: Store service prices in cents and fix contracts permission
- 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>
2025-12-10 03:37:13 -05:00

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;