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:
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;
|
||||
Reference in New Issue
Block a user