Files
smoothschedule/frontend/src/components/dashboard/CapacityWidget.tsx
poduck dcb14503a2 feat: Dashboard redesign, plan permissions, and help docs improvements
Major updates including:
- Customizable dashboard with drag-and-drop widget grid layout
- Plan-based feature locking for plugins and tasks
- Comprehensive help documentation updates across all pages
- Plugin seeding in deployment process for all tenants
- Permission synchronization system for subscription plans
- QuotaOverageModal component and enhanced UX flows

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 13:02:44 -05:00

141 lines
5.2 KiB
TypeScript

import React, { useMemo } from 'react';
import { GripVertical, X, Users, User } from 'lucide-react';
import { Appointment, Resource } from '../../types';
import { startOfWeek, endOfWeek, isWithinInterval } from 'date-fns';
interface CapacityWidgetProps {
appointments: Appointment[];
resources: Resource[];
isEditing?: boolean;
onRemove?: () => void;
}
const CapacityWidget: React.FC<CapacityWidgetProps> = ({
appointments,
resources,
isEditing,
onRemove,
}) => {
const capacityData = useMemo(() => {
const now = new Date();
const weekStart = startOfWeek(now, { weekStartsOn: 1 });
const weekEnd = endOfWeek(now, { weekStartsOn: 1 });
// Calculate for each resource
const resourceStats = resources.map((resource) => {
// Filter appointments for this resource this week
const resourceAppointments = appointments.filter(
(appt) =>
appt.resourceId === resource.id &&
isWithinInterval(new Date(appt.startTime), { start: weekStart, end: weekEnd }) &&
appt.status !== 'CANCELLED'
);
// Calculate total booked minutes
const bookedMinutes = resourceAppointments.reduce(
(sum, appt) => sum + appt.durationMinutes,
0
);
// Assume 8 hours/day, 5 days/week = 2400 minutes capacity
const totalCapacityMinutes = 8 * 60 * 5;
const utilization = Math.min((bookedMinutes / totalCapacityMinutes) * 100, 100);
return {
id: resource.id,
name: resource.name,
utilization: Math.round(utilization),
bookedHours: Math.round(bookedMinutes / 60),
};
});
// Calculate overall utilization
const totalBooked = resourceStats.reduce((sum, r) => sum + r.bookedHours, 0);
const totalCapacity = resources.length * 40; // 40 hours/week per resource
const overallUtilization = totalCapacity > 0 ? Math.round((totalBooked / totalCapacity) * 100) : 0;
return {
overall: overallUtilization,
resources: resourceStats.sort((a, b) => b.utilization - a.utilization),
};
}, [appointments, resources]);
const getUtilizationColor = (utilization: number) => {
if (utilization >= 80) return 'bg-green-500';
if (utilization >= 50) return 'bg-yellow-500';
return 'bg-gray-300 dark:bg-gray-600';
};
const getUtilizationTextColor = (utilization: number) => {
if (utilization >= 80) return 'text-green-600 dark:text-green-400';
if (utilization >= 50) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-500 dark:text-gray-400';
};
return (
<div className="h-full p-3 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group flex flex-col">
{isEditing && (
<>
<div className="absolute top-2 left-2 cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 drag-handle">
<GripVertical size={16} />
</div>
<button
onClick={onRemove}
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={14} />
</button>
</>
)}
<div className={`flex items-center justify-between mb-3 ${isEditing ? 'pl-5' : ''}`}>
<h3 className="text-base font-semibold text-gray-900 dark:text-white">
Capacity This Week
</h3>
<div className="flex items-center gap-1.5">
<Users size={14} className="text-gray-400" />
<span className="text-lg font-bold text-gray-900 dark:text-white">
{capacityData.overall}%
</span>
</div>
</div>
{capacityData.resources.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<Users size={32} className="mb-2 opacity-50" />
<p className="text-sm">No resources configured</p>
</div>
) : (
<div className="flex-1 grid grid-cols-2 gap-2 auto-rows-min">
{capacityData.resources.map((resource) => (
<div
key={resource.id}
className="p-2 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center gap-1.5 mb-1.5">
<User size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-600 dark:text-gray-300 truncate">
{resource.name}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
<div
className={`h-full ${getUtilizationColor(resource.utilization)} transition-all duration-300`}
style={{ width: `${resource.utilization}%` }}
/>
</div>
<span className={`text-xs font-semibold ${getUtilizationTextColor(resource.utilization)}`}>
{resource.utilization}%
</span>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default CapacityWidget;