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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user