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>
This commit is contained in:
140
frontend/src/components/dashboard/CapacityWidget.tsx
Normal file
140
frontend/src/components/dashboard/CapacityWidget.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user