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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
116
frontend/src/utils/quotaUtils.ts
Normal file
116
frontend/src/utils/quotaUtils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user