diff --git a/frontend/src/layouts/BusinessLayout.tsx b/frontend/src/layouts/BusinessLayout.tsx index 334ae2e..ff926b1 100644 --- a/frontend/src/layouts/BusinessLayout.tsx +++ b/frontend/src/layouts/BusinessLayout.tsx @@ -5,6 +5,7 @@ import TopBar from '../components/TopBar'; import TrialBanner from '../components/TrialBanner'; import SandboxBanner from '../components/SandboxBanner'; import QuotaWarningBanner from '../components/QuotaWarningBanner'; +import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal'; import { Business, User } from '../types'; import MasqueradeBanner from '../components/MasqueradeBanner'; import OnboardingWizard from '../components/OnboardingWizard'; @@ -108,9 +109,21 @@ const BusinessLayoutContent: React.FC = ({ business, user, }, []); const handleStopMasquerade = () => { + // Reset quota modal dismissal when returning from masquerade + resetQuotaOverageModalDismissal(); stopMasqueradeMutation.mutate(); }; + // Reset quota modal when user changes (masquerade start) + const prevUserIdRef = useRef(undefined); + useEffect(() => { + if (prevUserIdRef.current !== undefined && prevUserIdRef.current !== user.id) { + // User changed (masquerade started or changed) - reset modal + resetQuotaOverageModalDismissal(); + } + prevUserIdRef.current = user.id; + }, [user.id]); + useNotificationWebSocket(); // Activate the notification WebSocket listener // Get the previous user from the stack (the one we'll return to) @@ -193,6 +206,10 @@ const BusinessLayoutContent: React.FC = ({ business, user, {user.quota_overages && user.quota_overages.length > 0 && ( )} + {/* Quota overage modal - shows once per session on login/masquerade */} + {user.quota_overages && user.quota_overages.length > 0 && ( + {}} /> + )} {/* Sandbox mode banner */} {/* Show trial banner if trial is active and payments not yet enabled */} diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index dc45c04..923559c 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -3,14 +3,15 @@ */ import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Appointment, AppointmentStatus, User, Business } from '../types'; -import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check } from 'lucide-react'; +import { Appointment, AppointmentStatus, User, Business, Resource } from '../types'; +import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, X, User as UserIcon, Mail, Phone, Undo, Redo, ChevronLeft, ChevronRight, Check, AlertTriangle } from 'lucide-react'; import { useAppointments, useUpdateAppointment, useDeleteAppointment } from '../hooks/useAppointments'; import { useResources } from '../hooks/useResources'; import { useServices } from '../hooks/useServices'; import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket'; import Portal from '../components/Portal'; import EventAutomations from '../components/EventAutomations'; +import { getOverQuotaResourceIds } from '../utils/quotaUtils'; // Time settings const START_HOUR = 0; // Midnight @@ -82,6 +83,12 @@ const OwnerScheduler: React.FC = ({ user, business }) => { const updateMutation = useUpdateAppointment(); const deleteMutation = useDeleteAppointment(); + // Calculate over-quota resources (will be auto-archived when grace period ends) + const overQuotaResourceIds = useMemo( + () => getOverQuotaResourceIds(resources as Resource[], user.quota_overages), + [resources, user.quota_overages] + ); + // Connect to WebSocket for real-time updates useAppointmentWebSocket(); const [zoomLevel, setZoomLevel] = useState(1); @@ -1566,17 +1573,39 @@ const OwnerScheduler: React.FC = ({ user, business }) => {
Resources
- {resourceLayouts.map(layout => ( -
-
-
-
-

{layout.resource.name}

-

{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && {layout.laneCount} lanes}

+ {resourceLayouts.map(layout => { + const isOverQuota = overQuotaResourceIds.has(layout.resource.id); + return ( +
+
+
+ {isOverQuota ? : } +
+
+

{layout.resource.name}

+

+ {layout.resource.type.toLowerCase()} + {layout.laneCount > 1 && {layout.laneCount} lanes} + {isOverQuota && Over quota} +

+
-
- ))} + ); + })}
@@ -1670,14 +1699,17 @@ const OwnerScheduler: React.FC = ({ user, business }) => { ))}
- {resourceLayouts.map(layout => (
{layout.appointments.map(apt => { + {resourceLayouts.map(layout => { + const isResourceOverQuota = overQuotaResourceIds.has(layout.resource.id); + return (
{layout.appointments.map(apt => { const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP; const service = services.find(s => s.id === apt.serviceId); return (
handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}> {!isPreview && (<>
handleResizeStart(e, apt, 'start')} />
handleResizeStart(e, apt, 'end')} />)}
{apt.customerName}
{service?.name}
{apt.status === 'COMPLETED' ? : }{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}{formatDuration(apt.durationMinutes)}
); - })}
))} + })}
); + })}
diff --git a/frontend/src/pages/Resources.tsx b/frontend/src/pages/Resources.tsx index 1656f8f..36664f1 100644 --- a/frontend/src/pages/Resources.tsx +++ b/frontend/src/pages/Resources.tsx @@ -7,6 +7,7 @@ import { useAppointments } from '../hooks/useAppointments'; import { useStaff, StaffMember } from '../hooks/useStaff'; import ResourceCalendar from '../components/ResourceCalendar'; import Portal from '../components/Portal'; +import { getOverQuotaResourceIds } from '../utils/quotaUtils'; import { Plus, User as UserIcon, @@ -16,7 +17,8 @@ import { Calendar, Settings, X, - Pencil + Pencil, + AlertTriangle } from 'lucide-react'; const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => { @@ -45,6 +47,12 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => const [editingResource, setEditingResource] = React.useState(null); const [calendarResource, setCalendarResource] = React.useState<{ id: string; name: string } | null>(null); + // Calculate over-quota resources (will be auto-archived when grace period ends) + const overQuotaResourceIds = useMemo( + () => getOverQuotaResourceIds(resources, effectiveUser.quota_overages), + [resources, effectiveUser.quota_overages] + ); + // Form state const [formType, setFormType] = React.useState('STAFF'); const [formName, setFormName] = React.useState(''); @@ -328,18 +336,35 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => {resources.map((resource: Resource) => { + const isOverQuota = overQuotaResourceIds.has(resource.id); return (
-
- +
+ {isOverQuota ? : }
-
{resource.name}
+
+ {resource.name} + {isOverQuota && ( + + Over quota + + )} +
@@ -371,9 +396,16 @@ const Resources: React.FC = ({ onMasquerade, effectiveUser }) => - - {t('resources.active')} - + {isOverQuota ? ( + + + Over Quota + + ) : ( + + {t('resources.active')} + + )}
diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 580e47c..149fb08 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -1,8 +1,10 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image } from 'lucide-react'; +import { useOutletContext } from 'react-router-dom'; +import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle } from 'lucide-react'; import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices'; -import { Service } from '../types'; +import { Service, User, Business } from '../types'; +import { getOverQuotaServiceIds } from '../utils/quotaUtils'; interface ServiceFormData { name: string; @@ -14,12 +16,19 @@ interface ServiceFormData { const Services: React.FC = () => { const { t } = useTranslation(); + const { user } = useOutletContext<{ user: User, business: Business }>(); const { data: services, isLoading, error } = useServices(); const createService = useCreateService(); const updateService = useUpdateService(); const deleteService = useDeleteService(); const reorderServices = useReorderServices(); + // Calculate over-quota services (will be auto-archived when grace period ends) + const overQuotaServiceIds = useMemo( + () => getOverQuotaServiceIds(services || [], user.quota_overages), + [services, user.quota_overages] + ); + const [isModalOpen, setIsModalOpen] = useState(false); const [editingService, setEditingService] = useState(null); const [formData, setFormData] = useState({ @@ -304,7 +313,9 @@ const Services: React.FC = () => { {t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}

- {displayServices?.map((service) => ( + {displayServices?.map((service) => { + const isOverQuota = overQuotaServiceIds.has(service.id); + return (
{ onDragEnd={handleDragEnd} onDragOver={(e) => handleDragOver(e, service.id)} onDragLeave={handleDragLeave} - className={`p-4 bg-white dark:bg-gray-800 border rounded-xl shadow-sm cursor-move transition-all ${ - draggedId === service.id - ? 'opacity-50 border-brand-500' + className={`p-4 border rounded-xl shadow-sm cursor-move transition-all ${ + isOverQuota + ? 'bg-amber-50/50 dark:bg-amber-900/10 border-amber-300 dark:border-amber-600 opacity-70' + : draggedId === service.id + ? 'opacity-50 border-brand-500 bg-white dark:bg-gray-800' : dragOverId === service.id - ? 'border-brand-500 ring-2 ring-brand-500/50' - : 'border-gray-100 dark:border-gray-700' + ? 'border-brand-500 ring-2 ring-brand-500/50 bg-white dark:bg-gray-800' + : 'border-gray-100 dark:border-gray-700 bg-white dark:bg-gray-800' }`} + title={isOverQuota ? 'Over quota - will be archived if not resolved' : undefined} >
- + {isOverQuota ? ( + + ) : ( + + )}
-

+

{service.name} + {isOverQuota && ( + + Over quota + + )}

- ))} + ); + })}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d9ced76..68f7fcc 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -148,6 +148,8 @@ export interface Resource { userId?: string; maxConcurrentEvents: number; savedLaneCount?: number; // Remembered lane count when multilane is disabled + created_at?: string; // Used for quota overage calculation (oldest archived first) + is_archived_by_quota?: boolean; // True if archived due to quota overage } export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW'; @@ -205,6 +207,8 @@ export interface Service { description: string; displayOrder: number; photos?: string[]; + created_at?: string; // Used for quota overage calculation (oldest archived first) + is_archived_by_quota?: boolean; // True if archived due to quota overage } export interface Metric { diff --git a/frontend/src/utils/quotaUtils.ts b/frontend/src/utils/quotaUtils.ts new file mode 100644 index 0000000..145aa44 --- /dev/null +++ b/frontend/src/utils/quotaUtils.ts @@ -0,0 +1,116 @@ +/** + * Quota Utilities + * + * Helpers for identifying resources/services that are over quota and will be + * auto-archived when the grace period expires. + */ + +import { Resource, Service, QuotaOverage } from '../types'; + +/** + * Get the IDs of resources that are over quota and will be auto-archived. + * These are the oldest resources (by created_at) that exceed the limit. + * + * @param resources - All resources + * @param quotaOverages - Active quota overages + * @returns Set of resource IDs that are over quota + */ +export function getOverQuotaResourceIds( + resources: Resource[], + quotaOverages?: QuotaOverage[] +): Set { + const overQuotaIds = new Set(); + + if (!quotaOverages || quotaOverages.length === 0) { + return overQuotaIds; + } + + // Find MAX_RESOURCES overage + const resourceOverage = quotaOverages.find(o => o.quota_type === 'MAX_RESOURCES'); + if (!resourceOverage) { + return overQuotaIds; + } + + // Filter out already-archived resources and sort by created_at (oldest first) + const activeResources = resources + .filter(r => !r.is_archived_by_quota) + .sort((a, b) => { + const aDate = a.created_at ? new Date(a.created_at).getTime() : 0; + const bDate = b.created_at ? new Date(b.created_at).getTime() : 0; + return aDate - bDate; + }); + + // The first N resources (where N = overage_amount) are over quota + const overageCount = resourceOverage.overage_amount; + for (let i = 0; i < Math.min(overageCount, activeResources.length); i++) { + overQuotaIds.add(activeResources[i].id); + } + + return overQuotaIds; +} + +/** + * Get the IDs of services that are over quota and will be auto-archived. + * These are the oldest services (by created_at) that exceed the limit. + * + * @param services - All services + * @param quotaOverages - Active quota overages + * @returns Set of service IDs that are over quota + */ +export function getOverQuotaServiceIds( + services: Service[], + quotaOverages?: QuotaOverage[] +): Set { + const overQuotaIds = new Set(); + + if (!quotaOverages || quotaOverages.length === 0) { + return overQuotaIds; + } + + // Find MAX_SERVICES overage + const serviceOverage = quotaOverages.find(o => o.quota_type === 'MAX_SERVICES'); + if (!serviceOverage) { + return overQuotaIds; + } + + // Filter out already-archived services and sort by created_at (oldest first) + const activeServices = services + .filter(s => !s.is_archived_by_quota) + .sort((a, b) => { + const aDate = a.created_at ? new Date(a.created_at).getTime() : 0; + const bDate = b.created_at ? new Date(b.created_at).getTime() : 0; + return aDate - bDate; + }); + + // The first N services (where N = overage_amount) are over quota + const overageCount = serviceOverage.overage_amount; + for (let i = 0; i < Math.min(overageCount, activeServices.length); i++) { + overQuotaIds.add(activeServices[i].id); + } + + return overQuotaIds; +} + +/** + * Check if a specific resource is over quota (will be archived) + */ +export function isResourceOverQuota( + resourceId: string, + resources: Resource[], + quotaOverages?: QuotaOverage[] +): boolean { + const overQuotaIds = getOverQuotaResourceIds(resources, quotaOverages); + return overQuotaIds.has(resourceId); +} + +/** + * Check if a specific service is over quota (will be archived) + */ +export function isServiceOverQuota( + serviceId: string, + services: Service[], + quotaOverages?: QuotaOverage[] +): boolean { + const overQuotaIds = getOverQuotaServiceIds(services, quotaOverages); + return overQuotaIds.has(serviceId); +} diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index 91ba09c..13a9c30 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -137,8 +137,9 @@ class ServiceSerializer(serializers.ModelSerializer): fields = [ 'id', 'name', 'description', 'duration', 'duration_minutes', 'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at', + 'is_archived_by_quota', ] - read_only_fields = ['created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota'] class ResourceSerializer(serializers.ModelSerializer): @@ -154,8 +155,9 @@ class ResourceSerializer(serializers.ModelSerializer): 'description', 'max_concurrent_events', 'buffer_duration', 'is_active', 'capacity_description', 'saved_lane_count', 'created_at', 'updated_at', + 'is_archived_by_quota', ] - read_only_fields = ['created_at', 'updated_at'] + read_only_fields = ['created_at', 'updated_at', 'is_archived_by_quota'] def get_capacity_description(self, obj): if obj.max_concurrent_events == 0: diff --git a/smoothschedule/tickets/email_receiver.py b/smoothschedule/tickets/email_receiver.py index a9cc9a8..ff93d7e 100644 --- a/smoothschedule/tickets/email_receiver.py +++ b/smoothschedule/tickets/email_receiver.py @@ -192,6 +192,12 @@ class TicketEmailReceiver: # Extract email data email_data = self._extract_email_data(msg) + # Block emails sent to noreply@smoothschedule.com - just delete them + if email_data['to_address'] == 'noreply@smoothschedule.com': + logger.info(f"Deleting email sent to noreply@smoothschedule.com from {email_data['from_address']}") + self._delete_email(email_id) + return False + # Check for duplicate (by message ID) if IncomingTicketEmail.objects.filter(message_id=email_data['message_id']).exists(): logger.info(f"Duplicate email: {email_data['message_id']}") @@ -806,6 +812,12 @@ class PlatformEmailReceiver: email_data = self._extract_email_data(msg) + # Block emails sent to noreply@smoothschedule.com - just delete them + if email_data['to_address'] == 'noreply@smoothschedule.com': + logger.info(f"Deleting email sent to noreply@smoothschedule.com from {email_data['from_address']}") + self._delete_email(email_id) + return False + # Check for duplicate if IncomingTicketEmail.objects.filter(message_id=email_data['message_id']).exists(): logger.info(f"Duplicate email: {email_data['message_id']}") @@ -832,7 +844,11 @@ class PlatformEmailReceiver: if not ticket: # Create new ticket - return self._create_new_ticket_from_email(email_data, incoming_email, user) + success = self._create_new_ticket_from_email(email_data, incoming_email, user) + if success: + # Delete email from server after successful processing + self._delete_email(email_id) + return success # Add comment to existing ticket if not user: @@ -870,6 +886,8 @@ class PlatformEmailReceiver: ticket.save() incoming_email.mark_processed(ticket=ticket, user=user) + # Delete email from server after successful processing + self._delete_email(email_id) return True except Exception as e: