feat: Quota enforcement UI and various improvements

- Add quota limit warnings to Resources, Services, and OwnerScheduler pages
- Add quotaUtils.ts for checking quota limits
- Update BusinessLayout with quota context
- Improve email receiver logging
- Update serializers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-03 15:47:48 -05:00
parent fd751f02f8
commit 4f515c3710
8 changed files with 281 additions and 36 deletions

View File

@@ -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<BusinessLayoutProps> = ({ 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<string | undefined>(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<BusinessLayoutProps> = ({ business, user,
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaWarningBanner overages={user.quota_overages} />
)}
{/* Quota overage modal - shows once per session on login/masquerade */}
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaOverageModal overages={user.quota_overages} onDismiss={() => {}} />
)}
{/* Sandbox mode banner */}
<SandboxBannerWrapper />
{/* Show trial banner if trial is active and payments not yet enabled */}

View File

@@ -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<OwnerSchedulerProps> = ({ 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<OwnerSchedulerProps> = ({ user, business }) => {
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto flex-1">
{resourceLayouts.map(layout => (
<div key={layout.resource.id} className="flex items-center px-4 border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group" style={{ height: layout.height }}>
<div className="flex items-center gap-3 w-full">
<div className="flex items-center justify-center w-8 h-8 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0"><GripVertical size={16} /></div>
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{layout.resource.name}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">{layout.resource.type.toLowerCase()} {layout.laneCount > 1 && <span className="text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/50 px-1 rounded text-[10px]">{layout.laneCount} lanes</span>}</p>
{resourceLayouts.map(layout => {
const isOverQuota = overQuotaResourceIds.has(layout.resource.id);
return (
<div
key={layout.resource.id}
className={`flex items-center px-4 border-b border-gray-100 dark:border-gray-700 transition-colors group ${
isOverQuota
? 'bg-amber-50/50 dark:bg-amber-900/10 opacity-60'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
style={{ height: layout.height }}
title={isOverQuota ? 'Over quota - will be archived if not resolved' : undefined}
>
<div className="flex items-center gap-3 w-full">
<div className={`flex items-center justify-center w-8 h-8 rounded transition-colors shrink-0 ${
isOverQuota
? 'bg-amber-100 dark:bg-amber-800/50 text-amber-600 dark:text-amber-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 group-hover:bg-brand-100 dark:group-hover:bg-brand-900 group-hover:text-brand-600 dark:group-hover:text-brand-400'
}`}>
{isOverQuota ? <AlertTriangle size={16} /> : <GripVertical size={16} />}
</div>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm ${isOverQuota ? 'text-amber-800 dark:text-amber-300' : 'text-gray-900 dark:text-white'}`}>{layout.resource.name}</p>
<p className="text-xs text-gray-400 dark:text-gray-500 capitalize flex items-center gap-1">
{layout.resource.type.toLowerCase()}
{layout.laneCount > 1 && <span className="text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-900/50 px-1 rounded text-[10px]">{layout.laneCount} lanes</span>}
{isOverQuota && <span className="text-amber-600 dark:text-amber-400 bg-amber-100 dark:bg-amber-800/50 px-1 rounded text-[10px] ml-1">Over quota</span>}
</p>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
@@ -1670,14 +1699,17 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
</React.Fragment>
))}
</div>
{resourceLayouts.map(layout => (<div key={layout.resource.id} className="relative border-b border-gray-100 dark:border-gray-800 transition-colors" style={{ height: layout.height }}>{layout.appointments.map(apt => {
{resourceLayouts.map(layout => {
const isResourceOverQuota = overQuotaResourceIds.has(layout.resource.id);
return (<div key={layout.resource.id} className={`relative border-b border-gray-100 dark:border-gray-800 transition-colors ${isResourceOverQuota ? 'bg-amber-50/30 dark:bg-amber-900/10' : ''}`} style={{ height: layout.height }}>{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 (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={() => handleAppointmentClick(apt)}>
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
</div>);
})}</div>))}
})}</div>);
})}
</div>
</div>
</div>

View File

@@ -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<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
const [editingResource, setEditingResource] = React.useState<Resource | null>(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<ResourceType>('STAFF');
const [formName, setFormName] = React.useState('');
@@ -328,18 +336,35 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{resources.map((resource: Resource) => {
const isOverQuota = overQuotaResourceIds.has(resource.id);
return (
<tr
key={resource.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"
className={`transition-colors group ${
isOverQuota
? 'bg-amber-50/50 dark:bg-amber-900/10 opacity-70'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/30'
}`}
title={isOverQuota ? 'Over quota - will be archived if not resolved' : undefined}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center overflow-hidden border border-gray-200 dark:border-gray-600">
<ResourceIcon type={resource.type} />
<div className={`w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden border ${
isOverQuota
? 'bg-amber-100 dark:bg-amber-800/50 border-amber-300 dark:border-amber-600'
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600'
}`}>
{isOverQuota ? <AlertTriangle size={16} className="text-amber-600 dark:text-amber-400" /> : <ResourceIcon type={resource.type} />}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">{resource.name}</div>
<div className={`font-medium ${isOverQuota ? 'text-amber-800 dark:text-amber-300' : 'text-gray-900 dark:text-white'}`}>
{resource.name}
{isOverQuota && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400">
Over quota
</span>
)}
</div>
</div>
</div>
</td>
@@ -371,9 +396,16 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
</span>
</td>
<td className="px-6 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t('resources.active')}
</span>
{isOverQuota ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<AlertTriangle size={12} />
Over Quota
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
{t('resources.active')}
</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">

View File

@@ -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<Service | null>(null);
const [formData, setFormData] = useState<ServiceFormData>({
@@ -304,7 +313,9 @@ const Services: React.FC = () => {
{t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}
</p>
<div className="space-y-3">
{displayServices?.map((service) => (
{displayServices?.map((service) => {
const isOverQuota = overQuotaServiceIds.has(service.id);
return (
<div
key={service.id}
draggable
@@ -312,20 +323,32 @@ const Services: React.FC = () => {
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}
>
<div className="flex items-center gap-3">
<GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
{isOverQuota ? (
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 shrink-0" />
) : (
<GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
<h3 className={`font-semibold truncate ${isOverQuota ? 'text-amber-800 dark:text-amber-300' : 'text-gray-900 dark:text-white'}`}>
{service.name}
{isOverQuota && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400">
Over quota
</span>
)}
</h3>
<div className="flex items-center gap-1 shrink-0 ml-2">
<button
@@ -368,7 +391,8 @@ const Services: React.FC = () => {
</div>
</div>
</div>
))}
);
})}
</div>
</div>

View File

@@ -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 {

View File

@@ -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<string> {
const overQuotaIds = new Set<string>();
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<string> {
const overQuotaIds = new Set<string>();
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);
}