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;
|
||||
105
frontend/src/components/dashboard/ChartWidget.tsx
Normal file
105
frontend/src/components/dashboard/ChartWidget.tsx
Normal 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;
|
||||
134
frontend/src/components/dashboard/CustomerBreakdownWidget.tsx
Normal file
134
frontend/src/components/dashboard/CustomerBreakdownWidget.tsx
Normal 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;
|
||||
90
frontend/src/components/dashboard/MetricWidget.tsx
Normal file
90
frontend/src/components/dashboard/MetricWidget.tsx
Normal 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;
|
||||
144
frontend/src/components/dashboard/NoShowRateWidget.tsx
Normal file
144
frontend/src/components/dashboard/NoShowRateWidget.tsx
Normal 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;
|
||||
121
frontend/src/components/dashboard/OpenTicketsWidget.tsx
Normal file
121
frontend/src/components/dashboard/OpenTicketsWidget.tsx
Normal 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;
|
||||
144
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal file
144
frontend/src/components/dashboard/RecentActivityWidget.tsx
Normal 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;
|
||||
131
frontend/src/components/dashboard/WidgetConfigModal.tsx
Normal file
131
frontend/src/components/dashboard/WidgetConfigModal.tsx
Normal 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;
|
||||
9
frontend/src/components/dashboard/index.ts
Normal file
9
frontend/src/components/dashboard/index.ts
Normal 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';
|
||||
146
frontend/src/components/dashboard/types.ts
Normal file
146
frontend/src/components/dashboard/types.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user