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>
141 lines
5.2 KiB
TypeScript
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;
|