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:
poduck
2025-12-03 13:02:44 -05:00
parent 9444e26924
commit dcb14503a2
66 changed files with 7099 additions and 1467 deletions

View 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;

View File

@@ -0,0 +1,105 @@
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from 'recharts';
import { GripVertical, X } from 'lucide-react';
interface ChartData {
name: string;
value: number;
}
interface ChartWidgetProps {
title: string;
data: ChartData[];
type: 'bar' | 'line';
color?: string;
valuePrefix?: string;
isEditing?: boolean;
onRemove?: () => void;
}
const ChartWidget: React.FC<ChartWidgetProps> = ({
title,
data,
type,
color = '#3b82f6',
valuePrefix = '',
isEditing,
onRemove,
}) => {
const formatValue = (value: number) => `${valuePrefix}${value}`;
return (
<div className="h-full p-4 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>
</>
)}
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
{title}
</h3>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
{type === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tickFormatter={formatValue} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<Tooltip
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
formatter={(value: number) => [formatValue(value), title]}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
</BarChart>
) : (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#9CA3AF', fontSize: 12 }} />
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
formatter={(value: number) => [value, title]}
/>
<Line type="monotone" dataKey="value" stroke={color} strokeWidth={3} dot={{ r: 4, fill: color }} />
</LineChart>
)}
</ResponsiveContainer>
</div>
</div>
);
};
export default ChartWidget;

View File

@@ -0,0 +1,134 @@
import React, { useMemo } from 'react';
import { GripVertical, X, Users, UserPlus, UserCheck } from 'lucide-react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts';
import { Customer } from '../../types';
interface CustomerBreakdownWidgetProps {
customers: Customer[];
isEditing?: boolean;
onRemove?: () => void;
}
const CustomerBreakdownWidget: React.FC<CustomerBreakdownWidgetProps> = ({
customers,
isEditing,
onRemove,
}) => {
const breakdownData = useMemo(() => {
// Customers with lastVisit are returning, without are new
const returning = customers.filter((c) => c.lastVisit !== null).length;
const newCustomers = customers.filter((c) => c.lastVisit === null).length;
const total = customers.length;
return {
new: newCustomers,
returning,
total,
newPercentage: total > 0 ? Math.round((newCustomers / total) * 100) : 0,
returningPercentage: total > 0 ? Math.round((returning / total) * 100) : 0,
chartData: [
{ name: 'New', value: newCustomers, color: '#8b5cf6' },
{ name: 'Returning', value: returning, color: '#10b981' },
],
};
}, [customers]);
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>
</>
)}
<h3 className={`text-base font-semibold text-gray-900 dark:text-white mb-2 ${isEditing ? 'pl-5' : ''}`}>
Customers This Month
</h3>
<div className="flex-1 flex items-center gap-3 min-h-0">
{/* Pie Chart */}
<div className="w-20 h-20 flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={breakdownData.chartData}
cx="50%"
cy="50%"
innerRadius={20}
outerRadius={35}
paddingAngle={2}
dataKey="value"
>
{breakdownData.chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '8px',
border: 'none',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
backgroundColor: '#1F2937',
color: '#F3F4F6',
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div className="p-1 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<UserPlus size={12} className="text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">New</p>
<p className="text-base font-semibold text-gray-900 dark:text-white">
{breakdownData.new}{' '}
<span className="text-xs font-normal text-gray-400">
({breakdownData.newPercentage}%)
</span>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="p-1 rounded-lg bg-green-100 dark:bg-green-900/30">
<UserCheck size={12} className="text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Returning</p>
<p className="text-base font-semibold text-gray-900 dark:text-white">
{breakdownData.returning}{' '}
<span className="text-xs font-normal text-gray-400">
({breakdownData.returningPercentage}%)
</span>
</p>
</div>
</div>
</div>
</div>
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<Users size={12} />
<span>Total Customers</span>
</div>
<span className="font-semibold text-gray-900 dark:text-white">{breakdownData.total}</span>
</div>
</div>
</div>
);
};
export default CustomerBreakdownWidget;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { TrendingUp, TrendingDown, Minus, GripVertical, X } from 'lucide-react';
interface GrowthData {
weekly: { value: number; change: number };
monthly: { value: number; change: number };
}
interface MetricWidgetProps {
title: string;
value: number | string;
growth: GrowthData;
icon?: React.ReactNode;
isEditing?: boolean;
onRemove?: () => void;
}
const MetricWidget: React.FC<MetricWidgetProps> = ({
title,
value,
growth,
icon,
isEditing,
onRemove,
}) => {
const formatChange = (change: number) => {
if (change === 0) return '0%';
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
};
const getTrendIcon = (change: number) => {
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
return <Minus size={12} className="mr-1" />;
};
const getTrendClass = (change: number) => {
if (change > 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
if (change < 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
};
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
{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={isEditing ? 'pl-5' : ''}>
<div className="flex items-center gap-2 mb-2">
{icon && <span className="text-brand-500">{icon}</span>}
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{title}</p>
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
{value}
</div>
<div className="flex flex-wrap gap-2 text-xs">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Week:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.weekly.change)}`}>
{getTrendIcon(growth.weekly.change)}
{formatChange(growth.weekly.change)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Month:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(growth.monthly.change)}`}>
{getTrendIcon(growth.monthly.change)}
{formatChange(growth.monthly.change)}
</span>
</div>
</div>
</div>
</div>
);
};
export default MetricWidget;

View File

@@ -0,0 +1,144 @@
import React, { useMemo } from 'react';
import { GripVertical, X, UserX, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { Appointment } from '../../types';
import { subDays, subMonths, isAfter } from 'date-fns';
interface NoShowRateWidgetProps {
appointments: Appointment[];
isEditing?: boolean;
onRemove?: () => void;
}
const NoShowRateWidget: React.FC<NoShowRateWidgetProps> = ({
appointments,
isEditing,
onRemove,
}) => {
const noShowData = useMemo(() => {
const now = new Date();
const oneWeekAgo = subDays(now, 7);
const twoWeeksAgo = subDays(now, 14);
const oneMonthAgo = subMonths(now, 1);
const twoMonthsAgo = subMonths(now, 2);
// Calculate rates for different periods
const calculateRate = (appts: Appointment[]) => {
const completed = appts.filter(
(a) => a.status === 'COMPLETED' || a.status === 'NO_SHOW' || a.status === 'CANCELLED'
);
const noShows = completed.filter((a) => a.status === 'NO_SHOW');
return completed.length > 0 ? (noShows.length / completed.length) * 100 : 0;
};
// Current week
const thisWeekAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneWeekAgo));
const currentWeekRate = calculateRate(thisWeekAppts);
// Last week
const lastWeekAppts = appointments.filter(
(a) => isAfter(new Date(a.startTime), twoWeeksAgo) && !isAfter(new Date(a.startTime), oneWeekAgo)
);
const lastWeekRate = calculateRate(lastWeekAppts);
// Current month
const thisMonthAppts = appointments.filter((a) => isAfter(new Date(a.startTime), oneMonthAgo));
const currentMonthRate = calculateRate(thisMonthAppts);
// Last month
const lastMonthAppts = appointments.filter(
(a) => isAfter(new Date(a.startTime), twoMonthsAgo) && !isAfter(new Date(a.startTime), oneMonthAgo)
);
const lastMonthRate = calculateRate(lastMonthAppts);
// Calculate changes (negative is good for no-show rate)
const weeklyChange = lastWeekRate !== 0 ? ((currentWeekRate - lastWeekRate) / lastWeekRate) * 100 : 0;
const monthlyChange = lastMonthRate !== 0 ? ((currentMonthRate - lastMonthRate) / lastMonthRate) * 100 : 0;
// Count total no-shows this month
const noShowsThisMonth = thisMonthAppts.filter((a) => a.status === 'NO_SHOW').length;
return {
currentRate: currentMonthRate,
noShowCount: noShowsThisMonth,
weeklyChange,
monthlyChange,
};
}, [appointments]);
const formatChange = (change: number) => {
if (change === 0) return '0%';
return change > 0 ? `+${change.toFixed(1)}%` : `${change.toFixed(1)}%`;
};
// For no-show rate, down is good (green), up is bad (red)
const getTrendIcon = (change: number) => {
if (change < 0) return <TrendingDown size={12} className="mr-1" />;
if (change > 0) return <TrendingUp size={12} className="mr-1" />;
return <Minus size={12} className="mr-1" />;
};
const getTrendClass = (change: number) => {
if (change < 0) return 'text-green-700 bg-green-50 dark:bg-green-900/30 dark:text-green-400';
if (change > 0) return 'text-red-700 bg-red-50 dark:bg-red-900/30 dark:text-red-400';
return 'text-gray-700 bg-gray-50 dark:bg-gray-700 dark:text-gray-300';
};
const getRateColor = (rate: number) => {
if (rate <= 5) return 'text-green-600 dark:text-green-400';
if (rate <= 10) return 'text-yellow-600 dark:text-yellow-400';
return 'text-red-600 dark:text-red-400';
};
return (
<div className="h-full p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm relative group">
{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={isEditing ? 'pl-5' : ''}>
<div className="flex items-center gap-2 mb-2">
<UserX size={18} className="text-gray-400" />
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">No-Show Rate</p>
</div>
<div className="flex items-baseline gap-2 mb-1">
<span className={`text-2xl font-bold ${getRateColor(noShowData.currentRate)}`}>
{noShowData.currentRate.toFixed(1)}%
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
({noShowData.noShowCount} this month)
</span>
</div>
<div className="flex flex-wrap gap-2 text-xs mt-2">
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Week:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.weeklyChange)}`}>
{getTrendIcon(noShowData.weeklyChange)}
{formatChange(noShowData.weeklyChange)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-gray-500 dark:text-gray-400">Month:</span>
<span className={`flex items-center px-1.5 py-0.5 rounded-full ${getTrendClass(noShowData.monthlyChange)}`}>
{getTrendIcon(noShowData.monthlyChange)}
{formatChange(noShowData.monthlyChange)}
</span>
</div>
</div>
</div>
</div>
);
};
export default NoShowRateWidget;

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { GripVertical, X, AlertCircle, Clock, ChevronRight } from 'lucide-react';
import { Ticket } from '../../types';
import { formatDistanceToNow } from 'date-fns';
interface OpenTicketsWidgetProps {
tickets: Ticket[];
isEditing?: boolean;
onRemove?: () => void;
}
const OpenTicketsWidget: React.FC<OpenTicketsWidgetProps> = ({
tickets,
isEditing,
onRemove,
}) => {
const openTickets = tickets.filter(t => t.status === 'open' || t.status === 'in_progress');
const urgentCount = openTickets.filter(t => t.priority === 'urgent' || t.isOverdue).length;
const getPriorityColor = (priority: string, isOverdue?: boolean) => {
if (isOverdue) return 'text-red-600 dark:text-red-400';
switch (priority) {
case 'urgent': return 'text-red-600 dark:text-red-400';
case 'high': return 'text-orange-600 dark:text-orange-400';
case 'medium': return 'text-yellow-600 dark:text-yellow-400';
default: return 'text-gray-600 dark:text-gray-400';
}
};
const getPriorityBg = (priority: string, isOverdue?: boolean) => {
if (isOverdue) return 'bg-red-50 dark:bg-red-900/20';
switch (priority) {
case 'urgent': return 'bg-red-50 dark:bg-red-900/20';
case 'high': return 'bg-orange-50 dark:bg-orange-900/20';
case 'medium': return 'bg-yellow-50 dark:bg-yellow-900/20';
default: return 'bg-gray-50 dark:bg-gray-700/50';
}
};
return (
<div className="h-full p-4 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-4 ${isEditing ? 'pl-5' : ''}`}>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Open Tickets
</h3>
<div className="flex items-center gap-2">
{urgentCount > 0 && (
<span className="flex items-center gap-1 text-xs font-medium px-2 py-1 rounded-full bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400">
<AlertCircle size={12} />
{urgentCount} urgent
</span>
)}
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
{openTickets.length} open
</span>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{openTickets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<AlertCircle size={32} className="mb-2 opacity-50" />
<p className="text-sm">No open tickets</p>
</div>
) : (
openTickets.slice(0, 5).map((ticket) => (
<Link
key={ticket.id}
to="/tickets"
className={`block p-3 rounded-lg ${getPriorityBg(ticket.priority, ticket.isOverdue)} hover:opacity-80 transition-opacity`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{ticket.subject}
</p>
<div className="flex items-center gap-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
<span className={getPriorityColor(ticket.priority, ticket.isOverdue)}>
{ticket.isOverdue ? 'Overdue' : ticket.priority}
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })}
</span>
</div>
</div>
<ChevronRight size={16} className="text-gray-400 flex-shrink-0" />
</div>
</Link>
))
)}
</div>
{openTickets.length > 5 && (
<Link
to="/tickets"
className="mt-3 text-sm text-brand-600 dark:text-brand-400 hover:underline text-center"
>
View all {openTickets.length} tickets
</Link>
)}
</div>
);
};
export default OpenTicketsWidget;

View File

@@ -0,0 +1,144 @@
import React, { useMemo } from 'react';
import { GripVertical, X, Calendar, UserPlus, XCircle, CheckCircle, DollarSign } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Appointment, Customer } from '../../types';
interface ActivityItem {
id: string;
type: 'booking' | 'cancellation' | 'completion' | 'new_customer' | 'payment';
title: string;
description: string;
timestamp: Date;
icon: React.ReactNode;
iconBg: string;
}
interface RecentActivityWidgetProps {
appointments: Appointment[];
customers: Customer[];
isEditing?: boolean;
onRemove?: () => void;
}
const RecentActivityWidget: React.FC<RecentActivityWidgetProps> = ({
appointments,
customers,
isEditing,
onRemove,
}) => {
const activities = useMemo(() => {
const items: ActivityItem[] = [];
// Add appointments as activity
appointments.forEach((appt) => {
const timestamp = new Date(appt.startTime);
if (appt.status === 'CONFIRMED' || appt.status === 'PENDING') {
items.push({
id: `booking-${appt.id}`,
type: 'booking',
title: 'New Booking',
description: `${appt.customerName} booked an appointment`,
timestamp,
icon: <Calendar size={14} />,
iconBg: 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400',
});
} else if (appt.status === 'CANCELLED') {
items.push({
id: `cancel-${appt.id}`,
type: 'cancellation',
title: 'Cancellation',
description: `${appt.customerName} cancelled their appointment`,
timestamp,
icon: <XCircle size={14} />,
iconBg: 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400',
});
} else if (appt.status === 'COMPLETED') {
items.push({
id: `complete-${appt.id}`,
type: 'completion',
title: 'Completed',
description: `${appt.customerName}'s appointment completed`,
timestamp,
icon: <CheckCircle size={14} />,
iconBg: 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400',
});
}
});
// Add recent customers (those with no lastVisit are new)
customers
.filter(c => !c.lastVisit)
.slice(0, 5)
.forEach((customer) => {
items.push({
id: `customer-${customer.id}`,
type: 'new_customer',
title: 'New Customer',
description: `${customer.name} signed up`,
timestamp: new Date(), // Approximate - would need createdAt field
icon: <UserPlus size={14} />,
iconBg: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400',
});
});
// Sort by timestamp descending
items.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
return items.slice(0, 10);
}, [appointments, customers]);
return (
<div className="h-full p-4 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>
</>
)}
<h3 className={`text-lg font-semibold text-gray-900 dark:text-white mb-4 ${isEditing ? 'pl-5' : ''}`}>
Recent Activity
</h3>
<div className="flex-1 overflow-y-auto">
{activities.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400 dark:text-gray-500">
<Calendar size={32} className="mb-2 opacity-50" />
<p className="text-sm">No recent activity</p>
</div>
) : (
<div className="space-y-3">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start gap-3">
<div className={`p-1.5 rounded-lg ${activity.iconBg} flex-shrink-0`}>
{activity.icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{activity.title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{activity.description}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{formatDistanceToNow(activity.timestamp, { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RecentActivityWidget;

View File

@@ -0,0 +1,131 @@
import React from 'react';
import { X, Plus, Check, LayoutDashboard, BarChart2, Ticket, Activity, Users, UserX, PieChart } from 'lucide-react';
import { WIDGET_DEFINITIONS, WidgetType } from './types';
interface WidgetConfigModalProps {
isOpen: boolean;
onClose: () => void;
activeWidgets: string[];
onToggleWidget: (widgetId: string) => void;
onResetLayout: () => void;
}
const WIDGET_ICONS: Record<WidgetType, React.ReactNode> = {
'appointments-metric': <LayoutDashboard size={18} />,
'customers-metric': <Users size={18} />,
'services-metric': <LayoutDashboard size={18} />,
'resources-metric': <LayoutDashboard size={18} />,
'revenue-chart': <BarChart2 size={18} />,
'appointments-chart': <BarChart2 size={18} />,
'open-tickets': <Ticket size={18} />,
'recent-activity': <Activity size={18} />,
'capacity-utilization': <Users size={18} />,
'no-show-rate': <UserX size={18} />,
'customer-breakdown': <PieChart size={18} />,
};
const WidgetConfigModal: React.FC<WidgetConfigModalProps> = ({
isOpen,
onClose,
activeWidgets,
onToggleWidget,
onResetLayout,
}) => {
if (!isOpen) return null;
const widgets = Object.values(WIDGET_DEFINITIONS);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Configure Dashboard Widgets
</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
Select which widgets to show on your dashboard. You can drag widgets to reposition them.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{widgets.map((widget) => {
const isActive = activeWidgets.includes(widget.id);
return (
<button
key={widget.id}
onClick={() => onToggleWidget(widget.id)}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors text-left ${
isActive
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div
className={`p-2 rounded-lg ${
isActive
? 'bg-brand-100 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{WIDGET_ICONS[widget.type]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p
className={`text-sm font-medium ${
isActive
? 'text-brand-700 dark:text-brand-300'
: 'text-gray-900 dark:text-white'
}`}
>
{widget.title}
</p>
{isActive && (
<Check size={14} className="text-brand-600 dark:text-brand-400" />
)}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{widget.description}
</p>
</div>
</button>
);
})}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onResetLayout}
className="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
);
};
export default WidgetConfigModal;

View File

@@ -0,0 +1,9 @@
export * from './types';
export { default as MetricWidget } from './MetricWidget';
export { default as ChartWidget } from './ChartWidget';
export { default as OpenTicketsWidget } from './OpenTicketsWidget';
export { default as RecentActivityWidget } from './RecentActivityWidget';
export { default as CapacityWidget } from './CapacityWidget';
export { default as NoShowRateWidget } from './NoShowRateWidget';
export { default as CustomerBreakdownWidget } from './CustomerBreakdownWidget';
export { default as WidgetConfigModal } from './WidgetConfigModal';

View File

@@ -0,0 +1,146 @@
import { Layout } from 'react-grid-layout';
export type WidgetType =
| 'appointments-metric'
| 'customers-metric'
| 'services-metric'
| 'resources-metric'
| 'revenue-chart'
| 'appointments-chart'
| 'open-tickets'
| 'recent-activity'
| 'capacity-utilization'
| 'no-show-rate'
| 'customer-breakdown';
export interface WidgetConfig {
id: string;
type: WidgetType;
title: string;
description: string;
defaultSize: { w: number; h: number };
minSize?: { w: number; h: number };
}
export interface DashboardLayout {
widgets: string[]; // Widget IDs that are visible
layout: Layout[];
}
// Widget definitions with metadata
export const WIDGET_DEFINITIONS: Record<WidgetType, WidgetConfig> = {
'appointments-metric': {
id: 'appointments-metric',
type: 'appointments-metric',
title: 'Total Appointments',
description: 'Shows appointment count with weekly and monthly growth',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'customers-metric': {
id: 'customers-metric',
type: 'customers-metric',
title: 'Active Customers',
description: 'Shows customer count with weekly and monthly growth',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'services-metric': {
id: 'services-metric',
type: 'services-metric',
title: 'Services',
description: 'Shows number of services offered',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'resources-metric': {
id: 'resources-metric',
type: 'resources-metric',
title: 'Resources',
description: 'Shows number of resources available',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'revenue-chart': {
id: 'revenue-chart',
type: 'revenue-chart',
title: 'Revenue',
description: 'Weekly revenue bar chart',
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
},
'appointments-chart': {
id: 'appointments-chart',
type: 'appointments-chart',
title: 'Appointments Trend',
description: 'Weekly appointments line chart',
defaultSize: { w: 6, h: 4 },
minSize: { w: 4, h: 3 },
},
'open-tickets': {
id: 'open-tickets',
type: 'open-tickets',
title: 'Open Tickets',
description: 'Shows open support tickets requiring attention',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
'recent-activity': {
id: 'recent-activity',
type: 'recent-activity',
title: 'Recent Activity',
description: 'Timeline of recent business events',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
},
'capacity-utilization': {
id: 'capacity-utilization',
type: 'capacity-utilization',
title: 'Capacity Utilization',
description: 'Shows how booked your resources are this week',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
'no-show-rate': {
id: 'no-show-rate',
type: 'no-show-rate',
title: 'No-Show Rate',
description: 'Percentage of appointments marked as no-show',
defaultSize: { w: 3, h: 2 },
minSize: { w: 2, h: 2 },
},
'customer-breakdown': {
id: 'customer-breakdown',
type: 'customer-breakdown',
title: 'New vs Returning',
description: 'Customer breakdown this month',
defaultSize: { w: 4, h: 4 },
minSize: { w: 3, h: 3 },
},
};
// Default layout for new users
export const DEFAULT_LAYOUT: DashboardLayout = {
widgets: [
'appointments-metric',
'customers-metric',
'no-show-rate',
'revenue-chart',
'appointments-chart',
'open-tickets',
'recent-activity',
'capacity-utilization',
'customer-breakdown',
],
layout: [
{ i: 'appointments-metric', x: 0, y: 0, w: 4, h: 2 },
{ i: 'customers-metric', x: 4, y: 0, w: 4, h: 2 },
{ i: 'no-show-rate', x: 8, y: 0, w: 4, h: 2 },
{ i: 'revenue-chart', x: 0, y: 2, w: 6, h: 4 },
{ i: 'appointments-chart', x: 6, y: 2, w: 6, h: 4 },
{ i: 'open-tickets', x: 0, y: 6, w: 4, h: 4 },
{ i: 'recent-activity', x: 4, y: 6, w: 4, h: 4 },
{ i: 'capacity-utilization', x: 8, y: 6, w: 4, h: 4 },
{ i: 'customer-breakdown', x: 0, y: 10, w: 4, h: 4 },
],
};