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,268 @@
/**
* QuotaOverageModal Component
*
* Modal that appears on login/masquerade when the tenant has exceeded quotas.
* Shows warning about grace period and what will happen when it expires.
* Uses sessionStorage to only show once per session.
*/
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
X,
Clock,
Archive,
ChevronRight,
Users,
Layers,
Briefcase,
Mail,
Zap,
} from 'lucide-react';
import { QuotaOverage } from '../api/auth';
interface QuotaOverageModalProps {
overages: QuotaOverage[];
onDismiss: () => void;
}
const QUOTA_ICONS: Record<string, React.ReactNode> = {
'MAX_ADDITIONAL_USERS': <Users className="w-5 h-5" />,
'MAX_RESOURCES': <Layers className="w-5 h-5" />,
'MAX_SERVICES': <Briefcase className="w-5 h-5" />,
'MAX_EMAIL_TEMPLATES': <Mail className="w-5 h-5" />,
'MAX_AUTOMATED_TASKS': <Zap className="w-5 h-5" />,
};
const SESSION_STORAGE_KEY = 'quota_overage_modal_dismissed';
const QuotaOverageModal: React.FC<QuotaOverageModalProps> = ({ overages, onDismiss }) => {
const { t } = useTranslation();
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
// Check if already dismissed this session
const dismissed = sessionStorage.getItem(SESSION_STORAGE_KEY);
if (!dismissed && overages && overages.length > 0) {
setIsVisible(true);
}
}, [overages]);
const handleDismiss = () => {
sessionStorage.setItem(SESSION_STORAGE_KEY, 'true');
setIsVisible(false);
onDismiss();
};
if (!isVisible || !overages || overages.length === 0) {
return null;
}
// Find the most urgent overage (least days remaining)
const mostUrgent = overages.reduce((prev, curr) =>
curr.days_remaining < prev.days_remaining ? curr : prev
);
const isCritical = mostUrgent.days_remaining <= 1;
const isUrgent = mostUrgent.days_remaining <= 7;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-lg w-full overflow-hidden">
{/* Header */}
<div className={`px-6 py-4 ${
isCritical
? 'bg-red-600'
: isUrgent
? 'bg-amber-500'
: 'bg-amber-100 dark:bg-amber-900/30'
}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${
isCritical || isUrgent
? 'bg-white/20'
: 'bg-amber-200 dark:bg-amber-800'
}`}>
<AlertTriangle className={`w-6 h-6 ${
isCritical || isUrgent
? 'text-white'
: 'text-amber-700 dark:text-amber-300'
}`} />
</div>
<div>
<h2 className={`text-lg font-bold ${
isCritical || isUrgent
? 'text-white'
: 'text-amber-900 dark:text-amber-100'
}`}>
{isCritical
? t('quota.modal.titleCritical', 'Action Required Immediately!')
: isUrgent
? t('quota.modal.titleUrgent', 'Action Required Soon')
: t('quota.modal.title', 'Quota Exceeded')
}
</h2>
<p className={`text-sm ${
isCritical || isUrgent
? 'text-white/90'
: 'text-amber-700 dark:text-amber-200'
}`}>
{mostUrgent.days_remaining <= 0
? t('quota.modal.subtitleExpired', 'Grace period has expired')
: mostUrgent.days_remaining === 1
? t('quota.modal.subtitleOneDay', '1 day remaining')
: t('quota.modal.subtitle', '{{days}} days remaining', { days: mostUrgent.days_remaining })
}
</p>
</div>
</div>
<button
onClick={handleDismiss}
className={`p-2 rounded-lg transition-colors ${
isCritical || isUrgent
? 'hover:bg-white/20 text-white'
: 'hover:bg-amber-200 dark:hover:bg-amber-800 text-amber-700 dark:text-amber-300'
}`}
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5">
{/* Main message */}
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 dark:text-gray-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-gray-700 dark:text-gray-300">
<p className="font-medium mb-1">
{t('quota.modal.gracePeriodEnds', 'Grace period ends on {{date}}', {
date: formatDate(mostUrgent.grace_period_ends_at)
})}
</p>
<p>
{t('quota.modal.explanation',
'Your account has exceeded its plan limits. Please remove or archive excess items before the grace period ends, or they will be automatically archived.'
)}
</p>
</div>
</div>
{/* Overage list */}
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('quota.modal.overagesTitle', 'Items Over Quota')}
</h3>
<div className="space-y-2">
{overages.map((overage) => (
<div
key={overage.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
overage.days_remaining <= 1
? 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
: overage.days_remaining <= 7
? 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/30'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
overage.days_remaining <= 1
? 'bg-red-100 dark:bg-red-800/50 text-red-600 dark:text-red-400'
: overage.days_remaining <= 7
? 'bg-amber-100 dark:bg-amber-800/50 text-amber-600 dark:text-amber-400'
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300'
}`}>
{QUOTA_ICONS[overage.quota_type] || <Layers className="w-5 h-5" />}
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{overage.display_name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('quota.modal.usageInfo', '{{current}} used / {{limit}} allowed', {
current: overage.current_usage,
limit: overage.allowed_limit
})}
</p>
</div>
</div>
<div className="text-right">
<p className={`font-bold ${
overage.days_remaining <= 1
? 'text-red-600 dark:text-red-400'
: overage.days_remaining <= 7
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-600 dark:text-gray-300'
}`}>
+{overage.overage_amount}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('quota.modal.overLimit', 'over limit')}
</p>
</div>
</div>
))}
</div>
</div>
{/* What happens section */}
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Archive className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">
{t('quota.modal.whatHappens', 'What happens if I don\'t take action?')}
</p>
<p>
{t('quota.modal.autoArchiveExplanation',
'After the grace period ends, the oldest items over your limit will be automatically archived. Archived items remain in your account but cannot be used until you upgrade or remove other items.'
)}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between gap-3">
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t('quota.modal.dismissButton', 'Remind Me Later')}
</button>
<Link
to="/settings/quota"
onClick={handleDismiss}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
{t('quota.modal.manageButton', 'Manage Quota')}
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
);
};
export default QuotaOverageModal;
/**
* Clear the session storage dismissal flag
* Call this when user logs out or masquerade changes
*/
export const resetQuotaOverageModalDismissal = () => {
sessionStorage.removeItem(SESSION_STORAGE_KEY);
};

View File

@@ -119,6 +119,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
icon={Clock}
label={t('nav.tasks', 'Tasks')}
isCollapsed={isCollapsed}
locked={!canUse('plugins') || !canUse('tasks')}
/>
</SidebarSection>
@@ -193,7 +194,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
{canViewAdminPages && (
<SidebarSection title={t('nav.sections.extend', 'Extend')} isCollapsed={isCollapsed}>
<SidebarItem
to="/plugins/marketplace"
to="/plugins/my-plugins"
icon={Plug}
label={t('nav.plugins', 'Plugins')}
isCollapsed={isCollapsed}

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 },
],
};