feat: Quota overage system, updated tier pricing, and communication addons
Quota Overage System: - Add QuotaOverage model for tracking resource/user quota overages - Implement 30-day grace period with email notifications (immediate, 7-day, 1-day) - Add QuotaWarningBanner component in BusinessLayout - Add QuotaSettings page for managing overages and archiving resources - Add Celery tasks for automated quota checks and expiration handling - Add quota management API endpoints Updated Tier Pricing (Stripe: 2.9% + $0.30): - Free: No payments (requires addon) - Starter: 4% + $0.40 - Professional: 3.5% + $0.35 - Business: 3.25% + $0.32 - Enterprise: 3% + $0.30 New Subscription Addons: - Online Payments ($5/mo + 5% + $0.50) - for Free tier - SMS Notifications ($10/mo) - enables SMS reminders - Masked Calling ($15/mo) - enables anonymous calling BusinessEditModal Improvements: - Increased width to match PlanModal (max-w-3xl) - Added all tier options with auto-update on tier change - Added limits configuration and permissions sections Backend Fixes: - Fixed SubscriptionPlan serializer to include all communication fields - Allow blank business_tier for addon plans - Added migration for business_tier field changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,7 @@ const AuthenticationSettings = React.lazy(() => import('./pages/settings/Authent
|
||||
const EmailSettings = React.lazy(() => import('./pages/settings/EmailSettings'));
|
||||
const CommunicationSettings = React.lazy(() => import('./pages/settings/CommunicationSettings'));
|
||||
const BillingSettings = React.lazy(() => import('./pages/settings/BillingSettings'));
|
||||
const QuotaSettings = React.lazy(() => import('./pages/settings/QuotaSettings'));
|
||||
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
@@ -705,6 +706,7 @@ const AppContent: React.FC = () => {
|
||||
<Route path="email" element={<EmailSettings />} />
|
||||
<Route path="sms-calling" element={<CommunicationSettings />} />
|
||||
<Route path="billing" element={<BillingSettings />} />
|
||||
<Route path="quota" element={<QuotaSettings />} />
|
||||
</Route>
|
||||
) : (
|
||||
<Route path="/settings/*" element={<Navigate to="/" />} />
|
||||
|
||||
@@ -11,6 +11,17 @@ export interface LoginCredentials {
|
||||
|
||||
import { UserRole } from '../types';
|
||||
|
||||
export interface QuotaOverage {
|
||||
id: number;
|
||||
quota_type: string;
|
||||
display_name: string;
|
||||
current_usage: number;
|
||||
allowed_limit: number;
|
||||
overage_amount: number;
|
||||
days_remaining: number;
|
||||
grace_period_ends_at: string;
|
||||
}
|
||||
|
||||
export interface MasqueradeStackEntry {
|
||||
user_id: number;
|
||||
username: string;
|
||||
@@ -58,6 +69,10 @@ export interface User {
|
||||
business?: number;
|
||||
business_name?: string;
|
||||
business_subdomain?: string;
|
||||
permissions?: Record<string, boolean>;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
103
frontend/src/api/quota.ts
Normal file
103
frontend/src/api/quota.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Quota Management API
|
||||
*/
|
||||
|
||||
import apiClient from './client';
|
||||
import { QuotaOverage } from './auth';
|
||||
|
||||
export interface QuotaUsage {
|
||||
current: number;
|
||||
limit: number;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
export interface QuotaStatus {
|
||||
active_overages: QuotaOverage[];
|
||||
usage: Record<string, QuotaUsage>;
|
||||
}
|
||||
|
||||
export interface QuotaResource {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
duration?: number;
|
||||
price?: string;
|
||||
created_at: string | null;
|
||||
is_archived: boolean;
|
||||
archived_at: string | null;
|
||||
}
|
||||
|
||||
export interface QuotaResourcesResponse {
|
||||
quota_type: string;
|
||||
resources: QuotaResource[];
|
||||
}
|
||||
|
||||
export interface ArchiveResponse {
|
||||
archived_count: number;
|
||||
current_usage: number;
|
||||
limit: number;
|
||||
is_resolved: boolean;
|
||||
}
|
||||
|
||||
export interface QuotaOverageDetail extends QuotaOverage {
|
||||
status: string;
|
||||
created_at: string;
|
||||
initial_email_sent_at: string | null;
|
||||
week_reminder_sent_at: string | null;
|
||||
day_reminder_sent_at: string | null;
|
||||
archived_resource_ids: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current quota status
|
||||
*/
|
||||
export const getQuotaStatus = async (): Promise<QuotaStatus> => {
|
||||
const response = await apiClient.get<QuotaStatus>('/quota/status/');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get resources for a specific quota type
|
||||
*/
|
||||
export const getQuotaResources = async (quotaType: string): Promise<QuotaResourcesResponse> => {
|
||||
const response = await apiClient.get<QuotaResourcesResponse>(`/quota/resources/${quotaType}/`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Archive resources to resolve quota overage
|
||||
*/
|
||||
export const archiveResources = async (
|
||||
quotaType: string,
|
||||
resourceIds: number[]
|
||||
): Promise<ArchiveResponse> => {
|
||||
const response = await apiClient.post<ArchiveResponse>('/quota/archive/', {
|
||||
quota_type: quotaType,
|
||||
resource_ids: resourceIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unarchive a resource
|
||||
*/
|
||||
export const unarchiveResource = async (
|
||||
quotaType: string,
|
||||
resourceId: number
|
||||
): Promise<{ success: boolean; resource_id: number }> => {
|
||||
const response = await apiClient.post('/quota/unarchive/', {
|
||||
quota_type: quotaType,
|
||||
resource_id: resourceId,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get details for a specific overage
|
||||
*/
|
||||
export const getOverageDetail = async (overageId: number): Promise<QuotaOverageDetail> => {
|
||||
const response = await apiClient.get<QuotaOverageDetail>(`/quota/overages/${overageId}/`);
|
||||
return response.data;
|
||||
};
|
||||
131
frontend/src/components/QuotaWarningBanner.tsx
Normal file
131
frontend/src/components/QuotaWarningBanner.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X, ExternalLink } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { QuotaOverage } from '../api/auth';
|
||||
|
||||
interface QuotaWarningBannerProps {
|
||||
overages: QuotaOverage[];
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const QuotaWarningBanner: React.FC<QuotaWarningBannerProps> = ({ overages, onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!overages || overages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the most urgent overage (least days remaining)
|
||||
const mostUrgent = overages.reduce((prev, curr) =>
|
||||
curr.days_remaining < prev.days_remaining ? curr : prev
|
||||
);
|
||||
|
||||
const isUrgent = mostUrgent.days_remaining <= 7;
|
||||
const isCritical = mostUrgent.days_remaining <= 1;
|
||||
|
||||
const getBannerStyles = () => {
|
||||
if (isCritical) {
|
||||
return 'bg-red-600 text-white border-red-700';
|
||||
}
|
||||
if (isUrgent) {
|
||||
return 'bg-amber-500 text-white border-amber-600';
|
||||
}
|
||||
return 'bg-amber-100 text-amber-900 border-amber-300';
|
||||
};
|
||||
|
||||
const getIconColor = () => {
|
||||
if (isCritical || isUrgent) {
|
||||
return 'text-white';
|
||||
}
|
||||
return 'text-amber-600';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-b ${getBannerStyles()}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className={`h-5 w-5 flex-shrink-0 ${getIconColor()}`} />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2">
|
||||
<span className="font-medium">
|
||||
{isCritical
|
||||
? t('quota.banner.critical', 'URGENT: Automatic archiving tomorrow!')
|
||||
: isUrgent
|
||||
? t('quota.banner.urgent', 'Action Required: {{days}} days left', { days: mostUrgent.days_remaining })
|
||||
: t('quota.banner.warning', 'Quota exceeded for {{count}} item(s)', { count: overages.length })
|
||||
}
|
||||
</span>
|
||||
<span className="text-sm opacity-90">
|
||||
{t('quota.banner.details',
|
||||
'You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.',
|
||||
{
|
||||
overage: mostUrgent.overage_amount,
|
||||
type: mostUrgent.display_name,
|
||||
date: formatDate(mostUrgent.grace_period_ends_at)
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/settings/quota"
|
||||
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
isCritical || isUrgent
|
||||
? 'bg-white/20 hover:bg-white/30 text-white'
|
||||
: 'bg-amber-600 hover:bg-amber-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{t('quota.banner.manage', 'Manage Quota')}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className={`p-1 rounded-md transition-colors ${
|
||||
isCritical || isUrgent
|
||||
? 'hover:bg-white/20'
|
||||
: 'hover:bg-amber-200'
|
||||
}`}
|
||||
aria-label={t('common.dismiss', 'Dismiss')}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show additional overages if there are more than one */}
|
||||
{overages.length > 1 && (
|
||||
<div className="mt-2 text-sm opacity-90">
|
||||
<span className="font-medium">{t('quota.banner.allOverages', 'All overages:')}</span>
|
||||
<ul className="ml-4 mt-1 space-y-0.5">
|
||||
{overages.map((overage) => (
|
||||
<li key={overage.id}>
|
||||
{overage.display_name}: {overage.current_usage}/{overage.allowed_limit}
|
||||
({t('quota.banner.overBy', 'over by {{amount}}', { amount: overage.overage_amount })})
|
||||
{' - '}
|
||||
{overage.days_remaining <= 0
|
||||
? t('quota.banner.expiredToday', 'expires today!')
|
||||
: t('quota.banner.daysLeft', '{{days}} days left', { days: overage.days_remaining })
|
||||
}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaWarningBanner;
|
||||
@@ -543,7 +543,11 @@
|
||||
"acceptPayments": "Accept Payments",
|
||||
"acceptPaymentsDescription": "Enable payment acceptance from customers for appointments and services.",
|
||||
"stripeSetupRequired": "Stripe Connect Setup Required",
|
||||
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started."
|
||||
"stripeSetupDescription": "You'll need to complete Stripe onboarding to accept payments. Go to the Payments page to get started.",
|
||||
"quota": {
|
||||
"title": "Quota Management",
|
||||
"description": "Usage limits, archiving"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
@@ -1051,6 +1055,36 @@
|
||||
"dataRetention": "Your data is safe and will be retained for 30 days."
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"banner": {
|
||||
"critical": "URGENT: Automatic archiving tomorrow!",
|
||||
"urgent": "Action Required: {{days}} days left",
|
||||
"warning": "Quota exceeded for {{count}} item(s)",
|
||||
"details": "You have {{overage}} {{type}} over your plan limit. Grace period ends {{date}}.",
|
||||
"manage": "Manage Quota",
|
||||
"allOverages": "All overages:",
|
||||
"overBy": "over by {{amount}}",
|
||||
"expiredToday": "expires today!",
|
||||
"daysLeft": "{{days}} days left"
|
||||
},
|
||||
"page": {
|
||||
"title": "Quota Management",
|
||||
"subtitle": "Manage your account limits and usage",
|
||||
"currentUsage": "Current Usage",
|
||||
"planLimit": "Plan Limit",
|
||||
"overBy": "Over Limit By",
|
||||
"gracePeriodEnds": "Grace Period Ends",
|
||||
"daysRemaining": "{{days}} days remaining",
|
||||
"selectToArchive": "Select items to archive",
|
||||
"archiveSelected": "Archive Selected",
|
||||
"upgradeInstead": "Upgrade Plan Instead",
|
||||
"exportData": "Export Data",
|
||||
"archiveWarning": "Archived items will become read-only and cannot be used for new bookings.",
|
||||
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
|
||||
"noOverages": "You are within your plan limits.",
|
||||
"resolved": "Resolved! Your usage is now within limits."
|
||||
}
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Upgrade Your Plan",
|
||||
"subtitle": "Choose the perfect plan for {{businessName}}",
|
||||
|
||||
@@ -4,6 +4,7 @@ import Sidebar from '../components/Sidebar';
|
||||
import TopBar from '../components/TopBar';
|
||||
import TrialBanner from '../components/TrialBanner';
|
||||
import SandboxBanner from '../components/SandboxBanner';
|
||||
import QuotaWarningBanner from '../components/QuotaWarningBanner';
|
||||
import { Business, User } from '../types';
|
||||
import MasqueradeBanner from '../components/MasqueradeBanner';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
@@ -283,6 +284,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
|
||||
onStop={handleStopMasquerade}
|
||||
/>
|
||||
)}
|
||||
{/* Quota overage warning banner - show for owners and managers */}
|
||||
{user.quota_overages && user.quota_overages.length > 0 && (
|
||||
<QuotaWarningBanner overages={user.quota_overages} />
|
||||
)}
|
||||
{/* Sandbox mode banner */}
|
||||
<SandboxBannerWrapper />
|
||||
{/* Show trial banner if trial is active and payments not yet enabled */}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Phone,
|
||||
CreditCard,
|
||||
Webhook,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SettingsSidebarSection,
|
||||
@@ -137,6 +138,12 @@ const SettingsLayout: React.FC = () => {
|
||||
label={t('settings.billing.title', 'Plan & Billing')}
|
||||
description={t('settings.billing.description', 'Subscription, invoices')}
|
||||
/>
|
||||
<SettingsSidebarItem
|
||||
to="/settings/quota"
|
||||
icon={AlertTriangle}
|
||||
label={t('settings.quota.title', 'Quota Management')}
|
||||
description={t('settings.quota.description', 'Usage limits, archiving')}
|
||||
/>
|
||||
</SettingsSidebarSection>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@@ -785,12 +785,14 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
can_api_access: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
},
|
||||
// Default transaction fees: Stripe charges 2.9% + $0.30, so we need to charge more
|
||||
// Recommended: FREE 5%+50¢, STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢
|
||||
transaction_fee_percent: plan?.transaction_fee_percent
|
||||
? parseFloat(plan.transaction_fee_percent)
|
||||
: 0,
|
||||
: 4.0, // Default 4% for new plans
|
||||
transaction_fee_fixed: plan?.transaction_fee_fixed
|
||||
? parseFloat(plan.transaction_fee_fixed)
|
||||
: 0,
|
||||
: 40, // Default 40 cents for new plans
|
||||
// Communication pricing
|
||||
sms_enabled: plan?.sms_enabled ?? false,
|
||||
sms_price_per_message_cents: plan?.sms_price_per_message_cents ?? 3,
|
||||
@@ -1048,6 +1050,9 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
||||
Stripe charges 2.9% + $0.30 per transaction + $2/mo per connected account. Recommended: STARTER 4%+40¢, PROFESSIONAL 3.5%+35¢, ENTERPRISE 3%+30¢. Consider a payments add-on ($5/mo) for FREE tier users.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Communication Pricing */}
|
||||
@@ -1258,7 +1263,7 @@ const PlanModal: React.FC<PlanModalProps> = ({ plan, onSave, onClose, isLoading
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Users
|
||||
Max Additional Users
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
|
||||
@@ -1,8 +1,57 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Key } from 'lucide-react';
|
||||
import { X, Save, Key, RefreshCw } from 'lucide-react';
|
||||
import { useUpdateBusiness } from '../../../hooks/usePlatform';
|
||||
import { useSubscriptionPlans } from '../../../hooks/usePlatformSettings';
|
||||
import { PlatformBusiness } from '../../../api/platform';
|
||||
|
||||
// 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;
|
||||
@@ -11,34 +60,144 @@ interface BusinessEditModalProps {
|
||||
|
||||
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,
|
||||
max_services: 0,
|
||||
max_appointments: 0,
|
||||
max_email_templates: 0,
|
||||
max_automated_tasks: 0,
|
||||
// Platform Permissions
|
||||
can_manage_oauth_credentials: false,
|
||||
can_accept_payments: false,
|
||||
can_use_custom_domain: false,
|
||||
can_white_label: false,
|
||||
can_api_access: false,
|
||||
// New feature limits (not yet implemented)
|
||||
limits: {
|
||||
can_add_video_conferencing: false,
|
||||
max_event_types: null as number | null,
|
||||
max_calendars_connected: null as number | null,
|
||||
can_connect_to_api: false,
|
||||
can_book_repeated_events: false,
|
||||
can_require_2fa: false,
|
||||
can_download_logs: false,
|
||||
can_delete_data: false,
|
||||
// Extended Permissions
|
||||
permissions: {
|
||||
// Payments & Revenue
|
||||
can_process_refunds: false,
|
||||
can_create_packages: false,
|
||||
// Communication
|
||||
sms_reminders: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
can_use_pos: false,
|
||||
can_use_mobile_app: false,
|
||||
can_use_email_templates: false,
|
||||
// Customization
|
||||
can_customize_booking_page: false,
|
||||
// Advanced Features
|
||||
advanced_reporting: false,
|
||||
can_create_plugins: false,
|
||||
can_export_data: false,
|
||||
can_use_webhooks: false,
|
||||
calendar_sync: false,
|
||||
// Support & Enterprise
|
||||
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,
|
||||
max_services: plan.limits?.max_services ?? 0,
|
||||
max_appointments: plan.limits?.max_appointments ?? 0,
|
||||
max_email_templates: plan.limits?.max_email_templates ?? 0,
|
||||
max_automated_tasks: plan.limits?.max_automated_tasks ?? 0,
|
||||
// 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,
|
||||
// Extended Permissions
|
||||
permissions: {
|
||||
can_process_refunds: plan.permissions?.can_process_refunds ?? false,
|
||||
can_create_packages: plan.permissions?.can_create_packages ?? false,
|
||||
sms_reminders: plan.permissions?.sms_reminders ?? false,
|
||||
can_use_masked_phone_numbers: plan.permissions?.can_use_masked_phone_numbers ?? false,
|
||||
can_use_email_templates: plan.permissions?.can_use_email_templates ?? false,
|
||||
can_customize_booking_page: plan.permissions?.can_customize_booking_page ?? false,
|
||||
advanced_reporting: plan.permissions?.advanced_reporting ?? false,
|
||||
can_create_plugins: plan.permissions?.can_create_plugins ?? false,
|
||||
can_export_data: plan.permissions?.can_export_data ?? false,
|
||||
can_use_webhooks: plan.permissions?.can_use_webhooks ?? false,
|
||||
calendar_sync: plan.permissions?.calendar_sync ?? false,
|
||||
priority_support: plan.permissions?.priority_support ?? false,
|
||||
dedicated_support: plan.permissions?.dedicated_support ?? false,
|
||||
sso_enabled: plan.permissions?.sso_enabled ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback to static defaults
|
||||
const staticDefaults = TIER_DEFAULTS[tier] || TIER_DEFAULTS.FREE;
|
||||
return {
|
||||
...staticDefaults,
|
||||
max_services: 0,
|
||||
max_appointments: 0,
|
||||
max_email_templates: 0,
|
||||
max_automated_tasks: 0,
|
||||
permissions: {
|
||||
can_process_refunds: false,
|
||||
can_create_packages: false,
|
||||
sms_reminders: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
can_use_email_templates: false,
|
||||
can_customize_booking_page: false,
|
||||
advanced_reporting: false,
|
||||
can_create_plugins: false,
|
||||
can_export_data: false,
|
||||
can_use_webhooks: false,
|
||||
calendar_sync: false,
|
||||
priority_support: false,
|
||||
dedicated_support: false,
|
||||
sso_enabled: 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) {
|
||||
@@ -46,30 +205,51 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
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,
|
||||
max_services: (business as any).max_services || 0,
|
||||
max_appointments: (business as any).max_appointments || 0,
|
||||
max_email_templates: (business as any).max_email_templates || 0,
|
||||
max_automated_tasks: (business as any).max_automated_tasks || 0,
|
||||
// Platform Permissions
|
||||
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,
|
||||
limits: {
|
||||
can_add_video_conferencing: false,
|
||||
max_event_types: null,
|
||||
max_calendars_connected: null,
|
||||
can_connect_to_api: false,
|
||||
can_book_repeated_events: false,
|
||||
can_require_2fa: false,
|
||||
can_download_logs: false,
|
||||
can_delete_data: false,
|
||||
can_use_masked_phone_numbers: false,
|
||||
can_use_pos: false,
|
||||
can_use_mobile_app: false,
|
||||
// Extended Permissions
|
||||
permissions: {
|
||||
can_process_refunds: (business as any).permissions?.can_process_refunds || false,
|
||||
can_create_packages: (business as any).permissions?.can_create_packages || false,
|
||||
sms_reminders: (business as any).permissions?.sms_reminders || false,
|
||||
can_use_masked_phone_numbers: (business as any).permissions?.can_use_masked_phone_numbers || false,
|
||||
can_use_email_templates: (business as any).permissions?.can_use_email_templates || false,
|
||||
can_customize_booking_page: (business as any).permissions?.can_customize_booking_page || false,
|
||||
advanced_reporting: (business as any).permissions?.advanced_reporting || false,
|
||||
can_create_plugins: (business as any).permissions?.can_create_plugins || false,
|
||||
can_export_data: (business as any).permissions?.can_export_data || false,
|
||||
can_use_webhooks: (business as any).permissions?.can_use_webhooks || false,
|
||||
calendar_sync: (business as any).permissions?.calendar_sync || false,
|
||||
priority_support: (business as any).permissions?.priority_support || false,
|
||||
dedicated_support: (business as any).permissions?.dedicated_support || false,
|
||||
sso_enabled: (business as any).permissions?.sso_enabled || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [business]);
|
||||
|
||||
// Helper for permission changes
|
||||
const handlePermissionChange = (key: string, value: boolean) => {
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
permissions: {
|
||||
...prev.permissions,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditSave = () => {
|
||||
if (!business) return;
|
||||
|
||||
@@ -90,7 +270,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
|
||||
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-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<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">
|
||||
@@ -141,12 +321,22 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
|
||||
{/* Subscription Tier */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Subscription Tier
|
||||
</label>
|
||||
<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) => setEditForm({ ...editForm, subscription_tier: e.target.value })}
|
||||
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>
|
||||
@@ -154,303 +344,312 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
|
||||
<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 */}
|
||||
<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) || 1 })}
|
||||
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) || 1 })}
|
||||
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"
|
||||
/>
|
||||
{/* 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-3 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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Max Services
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={editForm.max_services}
|
||||
onChange={(e) => setEditForm({ ...editForm, max_services: 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 Appointments / Month
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={editForm.max_appointments}
|
||||
onChange={(e) => setEditForm({ ...editForm, max_appointments: 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 Email Templates
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={editForm.max_email_templates}
|
||||
onChange={(e) => setEditForm({ ...editForm, max_email_templates: 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 Automated Tasks
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="-1"
|
||||
value={editForm.max_automated_tasks}
|
||||
onChange={(e) => setEditForm({ ...editForm, max_automated_tasks: 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>
|
||||
|
||||
{/* Permissions Section */}
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
{/* Features & Permissions */}
|
||||
<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 flex items-center gap-2">
|
||||
<Key size={16} className="text-purple-500" />
|
||||
Platform Permissions
|
||||
</h4>
|
||||
Features & Permissions
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Control which features are available to this business.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Can Manage OAuth Credentials */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Manage OAuth Credentials
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Allow this business to configure their own OAuth app credentials
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, can_manage_oauth_credentials: !editForm.can_manage_oauth_credentials })}
|
||||
className={`${editForm.can_manage_oauth_credentials ? 'bg-purple-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-purple-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${editForm.can_manage_oauth_credentials ? '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>
|
||||
{/* Payments & Revenue */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Payments & Revenue</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_accept_payments}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_accept_payments: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Online Payments</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_process_refunds}
|
||||
onChange={(e) => handlePermissionChange('can_process_refunds', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Process Refunds</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_create_packages}
|
||||
onChange={(e) => handlePermissionChange('can_create_packages', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Service Packages</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Can Accept Payments */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Accept Online Payments
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enable Stripe Connect for payment processing
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, can_accept_payments: !editForm.can_accept_payments })}
|
||||
className={`${editForm.can_accept_payments ? 'bg-purple-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-purple-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${editForm.can_accept_payments ? '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>
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Communication</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.sms_reminders}
|
||||
onChange={(e) => handlePermissionChange('sms_reminders', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SMS Reminders</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_use_masked_phone_numbers}
|
||||
onChange={(e) => handlePermissionChange('can_use_masked_phone_numbers', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Masked Calling</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_use_email_templates}
|
||||
onChange={(e) => handlePermissionChange('can_use_email_templates', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Email Templates</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Can Use Custom Domain */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Use Custom Domain
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Allow custom domain configuration
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, can_use_custom_domain: !editForm.can_use_custom_domain })}
|
||||
className={`${editForm.can_use_custom_domain ? 'bg-purple-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-purple-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${editForm.can_use_custom_domain ? '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>
|
||||
{/* Customization */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Customization</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_customize_booking_page}
|
||||
onChange={(e) => handlePermissionChange('can_customize_booking_page', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Booking Page</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_use_custom_domain}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_use_custom_domain: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Custom Domains</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_white_label}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_white_label: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">White Labelling</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Can White Label */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Remove Branding
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Allow removal of SmoothSchedule branding
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, can_white_label: !editForm.can_white_label })}
|
||||
className={`${editForm.can_white_label ? 'bg-purple-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-purple-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${editForm.can_white_label ? '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>
|
||||
{/* Advanced Features */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Advanced Features</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.advanced_reporting}
|
||||
onChange={(e) => handlePermissionChange('advanced_reporting', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Advanced Analytics</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_api_access}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_api_access: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">API Access</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_create_plugins}
|
||||
onChange={(e) => handlePermissionChange('can_create_plugins', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Create Plugins</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_export_data}
|
||||
onChange={(e) => handlePermissionChange('can_export_data', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Data Export</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.can_use_webhooks}
|
||||
onChange={(e) => handlePermissionChange('can_use_webhooks', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Webhooks</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.calendar_sync}
|
||||
onChange={(e) => handlePermissionChange('calendar_sync', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Calendar Sync</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Can API Access */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
API Access
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Enable API access for integrations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditForm({ ...editForm, can_api_access: !editForm.can_api_access })}
|
||||
className={`${editForm.can_api_access ? 'bg-purple-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-purple-500`}
|
||||
role="switch"
|
||||
>
|
||||
<span className={`${editForm.can_api_access ? '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>
|
||||
{/* Support & Enterprise */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Support & Enterprise</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.can_manage_oauth_credentials}
|
||||
onChange={(e) => setEditForm({ ...editForm, can_manage_oauth_credentials: e.target.checked })}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Manage OAuth</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.priority_support}
|
||||
onChange={(e) => handlePermissionChange('priority_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Priority Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.dedicated_support}
|
||||
onChange={(e) => handlePermissionChange('dedicated_support', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Dedicated Support</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.permissions.sso_enabled}
|
||||
onChange={(e) => handlePermissionChange('sso_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">SSO / SAML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Limits (Not Yet Implemented) */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Feature Limits & Capabilities
|
||||
</label>
|
||||
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3 opacity-50">
|
||||
{/* Video Conferencing */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_add_video_conferencing}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can add video conferencing to events
|
||||
</label>
|
||||
|
||||
{/* Event Types Limit */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.max_event_types === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited event types</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
value={editForm.limits.max_event_types || ''}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 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 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendars Connected Limit */}
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.max_calendars_connected === null}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 mt-1 cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Unlimited calendar connections</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled
|
||||
value={editForm.limits.max_calendars_connected || ''}
|
||||
placeholder="Or set a limit"
|
||||
className="mt-1 w-32 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 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Access */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_connect_to_api}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can connect to external APIs
|
||||
</label>
|
||||
|
||||
{/* Repeated Events */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_book_repeated_events}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can book repeated/recurring events
|
||||
</label>
|
||||
|
||||
{/* 2FA */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_require_2fa}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can require 2FA for users
|
||||
</label>
|
||||
|
||||
{/* Download Logs */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_download_logs}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can download system logs
|
||||
</label>
|
||||
|
||||
{/* Delete Data */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_delete_data}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can permanently delete data
|
||||
</label>
|
||||
|
||||
{/* Masked Phone Numbers */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_use_masked_phone_numbers}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use masked phone numbers for privacy
|
||||
</label>
|
||||
|
||||
{/* POS Integration */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_use_pos}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use Point of Sale (POS) system
|
||||
</label>
|
||||
|
||||
{/* Mobile App */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.limits.can_use_mobile_app}
|
||||
disabled
|
||||
className="rounded border-gray-300 dark:border-gray-600 cursor-not-allowed"
|
||||
/>
|
||||
Can use mobile app
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
|
||||
467
frontend/src/pages/settings/QuotaSettings.tsx
Normal file
467
frontend/src/pages/settings/QuotaSettings.tsx
Normal file
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Quota Settings Page
|
||||
*
|
||||
* Manage quota overages by selecting which resources to archive.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext, Link } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle, Archive, Check, ChevronDown, ChevronUp,
|
||||
Clock, Download, Users, Briefcase, Calendar, RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { Business, User, QuotaOverage } from '../../types';
|
||||
import {
|
||||
getQuotaStatus,
|
||||
getQuotaResources,
|
||||
archiveResources,
|
||||
QuotaStatus,
|
||||
QuotaResource
|
||||
} from '../../api/quota';
|
||||
|
||||
const QuotaSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { business, user } = useOutletContext<{
|
||||
business: Business;
|
||||
user: User;
|
||||
}>();
|
||||
|
||||
const [quotaStatus, setQuotaStatus] = useState<QuotaStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedOverage, setExpandedOverage] = useState<number | null>(null);
|
||||
const [resources, setResources] = useState<Record<string, QuotaResource[]>>({});
|
||||
const [selectedResources, setSelectedResources] = useState<Record<string, Set<number>>>({});
|
||||
const [archiving, setArchiving] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const isOwner = user.role === 'owner';
|
||||
const isManager = user.role === 'manager';
|
||||
|
||||
useEffect(() => {
|
||||
loadQuotaStatus();
|
||||
}, []);
|
||||
|
||||
const loadQuotaStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const status = await getQuotaStatus();
|
||||
setQuotaStatus(status);
|
||||
|
||||
// Auto-expand first overage if any
|
||||
if (status.active_overages.length > 0) {
|
||||
setExpandedOverage(status.active_overages[0].id);
|
||||
await loadResources(status.active_overages[0].quota_type);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load quota status');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadResources = async (quotaType: string) => {
|
||||
if (resources[quotaType]) return; // Already loaded
|
||||
|
||||
try {
|
||||
const response = await getQuotaResources(quotaType);
|
||||
setResources(prev => ({
|
||||
...prev,
|
||||
[quotaType]: response.resources
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to load resources:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOverage = async (overage: QuotaOverage) => {
|
||||
if (expandedOverage === overage.id) {
|
||||
setExpandedOverage(null);
|
||||
} else {
|
||||
setExpandedOverage(overage.id);
|
||||
await loadResources(overage.quota_type);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleResourceSelection = (quotaType: string, resourceId: number) => {
|
||||
setSelectedResources(prev => {
|
||||
const current = prev[quotaType] || new Set();
|
||||
const newSet = new Set(current);
|
||||
if (newSet.has(resourceId)) {
|
||||
newSet.delete(resourceId);
|
||||
} else {
|
||||
newSet.add(resourceId);
|
||||
}
|
||||
return { ...prev, [quotaType]: newSet };
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchive = async (quotaType: string) => {
|
||||
const selected = selectedResources[quotaType];
|
||||
if (!selected || selected.size === 0) return;
|
||||
|
||||
try {
|
||||
setArchiving(true);
|
||||
const result = await archiveResources(quotaType, Array.from(selected));
|
||||
|
||||
// Clear selection and reload
|
||||
setSelectedResources(prev => ({ ...prev, [quotaType]: new Set() }));
|
||||
setResources(prev => {
|
||||
const { [quotaType]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
if (result.is_resolved) {
|
||||
setSuccessMessage(t('quota.page.resolved', 'Resolved! Your usage is now within limits.'));
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
}
|
||||
|
||||
await loadQuotaStatus();
|
||||
} catch (err) {
|
||||
setError('Failed to archive resources');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setArchiving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getQuotaIcon = (quotaType: string) => {
|
||||
switch (quotaType) {
|
||||
case 'MAX_ADDITIONAL_USERS':
|
||||
return <Users className="h-5 w-5" />;
|
||||
case 'MAX_RESOURCES':
|
||||
return <Briefcase className="h-5 w-5" />;
|
||||
case 'MAX_SERVICES':
|
||||
return <Calendar className="h-5 w-5" />;
|
||||
default:
|
||||
return <AlertTriangle className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOwner && !isManager) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Only business owners and managers can access quota settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-brand-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-red-600 dark:text-red-400">{error}</p>
|
||||
<button
|
||||
onClick={loadQuotaStatus}
|
||||
className="mt-4 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
{t('common.reload', 'Reload')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasOverages = quotaStatus && quotaStatus.active_overages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<AlertTriangle className="text-amber-500" />
|
||||
{t('quota.page.title', 'Quota Management')}
|
||||
</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('quota.page.subtitle', 'Manage your account limits and usage')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 flex items-center gap-3">
|
||||
<Check className="h-5 w-5 text-green-600" />
|
||||
<span className="text-green-800">{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No overages */}
|
||||
{!hasOverages && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-6 text-center">
|
||||
<Check className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-green-800 dark:text-green-200">
|
||||
{t('quota.page.noOverages', 'You are within your plan limits.')}
|
||||
</h3>
|
||||
<p className="text-green-600 dark:text-green-400 mt-2">
|
||||
All your resources are within the limits of your current plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Overview */}
|
||||
{quotaStatus && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
Current Usage
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{Object.entries(quotaStatus.usage).map(([quotaType, usage]) => {
|
||||
const isOver = usage.limit > 0 && usage.current > usage.limit;
|
||||
return (
|
||||
<div
|
||||
key={quotaType}
|
||||
className={`p-4 rounded-lg border ${
|
||||
isOver
|
||||
? 'border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-900/20'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{getQuotaIcon(quotaType)}
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{usage.display_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold">
|
||||
<span className={isOver ? 'text-red-600' : 'text-gray-900 dark:text-white'}>
|
||||
{usage.current}
|
||||
</span>
|
||||
<span className="text-gray-400 text-lg">
|
||||
{' / '}
|
||||
{usage.limit < 0 ? 'Unlimited' : usage.limit}
|
||||
</span>
|
||||
</div>
|
||||
{isOver && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
Over by {usage.current - usage.limit}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Overages */}
|
||||
{hasOverages && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Active Overages
|
||||
</h3>
|
||||
|
||||
{quotaStatus!.active_overages.map((overage) => (
|
||||
<div
|
||||
key={overage.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-amber-300 dark:border-amber-700 overflow-hidden"
|
||||
>
|
||||
{/* Overage Header */}
|
||||
<button
|
||||
onClick={() => toggleOverage(overage)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
overage.days_remaining <= 1
|
||||
? 'bg-red-100 text-red-600'
|
||||
: overage.days_remaining <= 7
|
||||
? 'bg-amber-100 text-amber-600'
|
||||
: 'bg-amber-50 text-amber-500'
|
||||
}`}>
|
||||
{getQuotaIcon(overage.quota_type)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">
|
||||
{overage.display_name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{overage.current_usage} / {overage.allowed_limit}
|
||||
{' • '}
|
||||
<span className="text-red-600 font-medium">
|
||||
{overage.overage_amount} over limit
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
overage.days_remaining <= 1
|
||||
? 'bg-red-100 text-red-700'
|
||||
: overage.days_remaining <= 7
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
<Clock className="h-4 w-4 inline mr-1" />
|
||||
{overage.days_remaining} days left
|
||||
</div>
|
||||
{expandedOverage === overage.id ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expandedOverage === overage.id && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 inline mr-1" />
|
||||
{t('quota.page.autoArchiveWarning',
|
||||
'After the grace period ({{date}}), the oldest {{count}} {{type}} will be automatically archived.',
|
||||
{
|
||||
date: formatDate(overage.grace_period_ends_at),
|
||||
count: overage.overage_amount,
|
||||
type: overage.display_name
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-3">
|
||||
{t('quota.page.selectToArchive', 'Select items to archive')}
|
||||
</h5>
|
||||
|
||||
{/* Resource List */}
|
||||
{resources[overage.quota_type] ? (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{resources[overage.quota_type]
|
||||
.filter(r => !r.is_archived)
|
||||
.map((resource) => (
|
||||
<label
|
||||
key={resource.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedResources[overage.quota_type]?.has(resource.id) || false}
|
||||
onChange={() => toggleResourceSelection(overage.quota_type, resource.id)}
|
||||
className="h-4 w-4 text-brand-600 rounded border-gray-300 focus:ring-brand-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{resource.name}
|
||||
</p>
|
||||
{resource.email && (
|
||||
<p className="text-sm text-gray-500">{resource.email}</p>
|
||||
)}
|
||||
{resource.role && (
|
||||
<p className="text-sm text-gray-500">{resource.role}</p>
|
||||
)}
|
||||
{resource.type && (
|
||||
<p className="text-sm text-gray-500">{resource.type}</p>
|
||||
)}
|
||||
</div>
|
||||
{resource.created_at && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Created {formatDate(resource.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already Archived */}
|
||||
{resources[overage.quota_type]?.some(r => r.is_archived) && (
|
||||
<div className="mt-4">
|
||||
<h6 className="text-sm font-medium text-gray-500 mb-2">Already Archived</h6>
|
||||
<div className="space-y-2">
|
||||
{resources[overage.quota_type]
|
||||
.filter(r => r.is_archived)
|
||||
.map((resource) => (
|
||||
<div
|
||||
key={resource.id}
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-gray-100 dark:bg-gray-700/50"
|
||||
>
|
||||
<Archive className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-500">{resource.name}</span>
|
||||
{resource.archived_at && (
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
Archived {formatDate(resource.archived_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => handleArchive(overage.quota_type)}
|
||||
disabled={
|
||||
archiving ||
|
||||
!selectedResources[overage.quota_type] ||
|
||||
selectedResources[overage.quota_type].size === 0
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{archiving ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Archive className="h-4 w-4" />
|
||||
)}
|
||||
{t('quota.page.archiveSelected', 'Archive Selected')}
|
||||
{selectedResources[overage.quota_type]?.size > 0 && (
|
||||
<span className="bg-amber-700 px-2 py-0.5 rounded-full text-xs">
|
||||
{selectedResources[overage.quota_type].size}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/settings/billing"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700"
|
||||
>
|
||||
{t('quota.page.upgradeInstead', 'Upgrade Plan Instead')}
|
||||
</Link>
|
||||
<button className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
|
||||
<Download className="h-4 w-4" />
|
||||
{t('quota.page.exportData', 'Export Data')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Archive Warning */}
|
||||
<p className="mt-3 text-sm text-gray-500">
|
||||
<AlertTriangle className="h-4 w-4 inline mr-1" />
|
||||
{t('quota.page.archiveWarning',
|
||||
'Archived items will become read-only and cannot be used for new bookings.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaSettings;
|
||||
@@ -22,3 +22,6 @@ export { default as CommunicationSettings } from './CommunicationSettings';
|
||||
|
||||
// Billing
|
||||
export { default as BillingSettings } from './BillingSettings';
|
||||
|
||||
// Quota Management
|
||||
export { default as QuotaSettings } from './QuotaSettings';
|
||||
|
||||
@@ -93,6 +93,17 @@ export interface NotificationPreferences {
|
||||
marketing: boolean;
|
||||
}
|
||||
|
||||
export interface QuotaOverage {
|
||||
id: number;
|
||||
quota_type: string;
|
||||
display_name: string;
|
||||
current_usage: number;
|
||||
allowed_limit: number;
|
||||
overage_amount: number;
|
||||
days_remaining: number;
|
||||
grace_period_ends_at: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string | number;
|
||||
username?: string;
|
||||
@@ -109,6 +120,7 @@ export interface User {
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
permissions?: Record<string, boolean>;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||
|
||||
Reference in New Issue
Block a user