396 lines
15 KiB
JavaScript
396 lines
15 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
|
import { base44 } from "@/api/base44Client";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Settings,
|
|
GripVertical,
|
|
X,
|
|
Plus,
|
|
Eye,
|
|
EyeOff,
|
|
Info,
|
|
Save,
|
|
RotateCcw,
|
|
Sparkles
|
|
} from "lucide-react";
|
|
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
export default function DashboardCustomizer({
|
|
user,
|
|
availableWidgets = [],
|
|
currentLayout = [],
|
|
onLayoutChange,
|
|
dashboardType = "default" // admin, client, vendor, operator, etc
|
|
}) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [showHowItWorks, setShowHowItWorks] = useState(false);
|
|
const [visibleWidgets, setVisibleWidgets] = useState([]);
|
|
const [hiddenWidgets, setHiddenWidgets] = useState([]);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Initialize widgets from user's saved layout or defaults
|
|
useEffect(() => {
|
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
|
const savedLayout = user?.[layoutKey];
|
|
|
|
if (savedLayout?.widgets && savedLayout.widgets.length > 0) {
|
|
const savedVisible = savedLayout.widgets
|
|
.map(id => availableWidgets.find(w => w.id === id))
|
|
.filter(Boolean);
|
|
setVisibleWidgets(savedVisible);
|
|
|
|
const savedHidden = savedLayout.hidden_widgets || [];
|
|
const hiddenWidgetsList = availableWidgets.filter(w =>
|
|
savedHidden.includes(w.id)
|
|
);
|
|
setHiddenWidgets(hiddenWidgetsList);
|
|
} else {
|
|
// Default: all widgets visible in provided order
|
|
setVisibleWidgets(availableWidgets);
|
|
setHiddenWidgets([]);
|
|
}
|
|
}, [user, availableWidgets, isOpen, dashboardType]);
|
|
|
|
// Save layout mutation
|
|
const saveLayoutMutation = useMutation({
|
|
mutationFn: async (layoutData) => {
|
|
const layoutKey = `dashboard_layout_${dashboardType}`;
|
|
await base44.auth.updateMe({
|
|
[layoutKey]: layoutData
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['current-user'] });
|
|
queryClient.invalidateQueries({ queryKey: ['current-user-layout'] });
|
|
queryClient.invalidateQueries({ queryKey: ['current-user-client'] });
|
|
queryClient.invalidateQueries({ queryKey: ['current-user-vendor'] });
|
|
queryClient.invalidateQueries({ queryKey: ['current-user-operator'] });
|
|
toast({
|
|
title: "✅ Layout Saved",
|
|
description: "Your dashboard layout has been updated",
|
|
});
|
|
setHasChanges(false);
|
|
if (onLayoutChange) {
|
|
onLayoutChange(visibleWidgets);
|
|
}
|
|
setTimeout(() => {
|
|
setIsOpen(false);
|
|
}, 500);
|
|
},
|
|
onError: () => {
|
|
toast({
|
|
title: "❌ Save Failed",
|
|
description: "Could not save your layout. Please try again.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
});
|
|
|
|
const handleDragEnd = (result) => {
|
|
if (!result.destination) return;
|
|
|
|
const { source, destination } = result;
|
|
|
|
if (source.droppableId === "visible" && destination.droppableId === "visible") {
|
|
const items = Array.from(visibleWidgets);
|
|
const [reorderedItem] = items.splice(source.index, 1);
|
|
items.splice(destination.index, 0, reorderedItem);
|
|
setVisibleWidgets(items);
|
|
setHasChanges(true);
|
|
}
|
|
};
|
|
|
|
const handleHideWidget = (widget) => {
|
|
setVisibleWidgets(visibleWidgets.filter(w => w.id !== widget.id));
|
|
setHiddenWidgets([...hiddenWidgets, widget]);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
const handleShowWidget = (widget) => {
|
|
setHiddenWidgets(hiddenWidgets.filter(w => w.id !== widget.id));
|
|
setVisibleWidgets([...visibleWidgets, widget]);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const layoutData = {
|
|
widgets: visibleWidgets.map(w => w.id),
|
|
hidden_widgets: hiddenWidgets.map(w => w.id),
|
|
layout_version: "2.0"
|
|
};
|
|
saveLayoutMutation.mutate(layoutData);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setVisibleWidgets(availableWidgets);
|
|
setHiddenWidgets([]);
|
|
setHasChanges(true);
|
|
};
|
|
|
|
const handleOpenCustomizer = () => {
|
|
setIsOpen(true);
|
|
setShowHowItWorks(true);
|
|
setHasChanges(false);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
if (hasChanges) {
|
|
if (window.confirm("You have unsaved changes. Are you sure you want to close?")) {
|
|
setIsOpen(false);
|
|
setHasChanges(false);
|
|
}
|
|
} else {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Customize Button */}
|
|
<Button
|
|
onClick={handleOpenCustomizer}
|
|
variant="outline"
|
|
className="gap-2 border-2 border-blue-200 hover:bg-blue-50 text-blue-600 font-semibold"
|
|
>
|
|
<Settings className="w-4 h-4" />
|
|
Customize
|
|
</Button>
|
|
|
|
{/* Customizer Dialog */}
|
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
|
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl font-bold flex items-center gap-2">
|
|
<Sparkles className="w-6 h-6 text-blue-600" />
|
|
Customize Your Dashboard
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
Personalize your workspace by adding, removing, and reordering widgets
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* How It Works Banner */}
|
|
<AnimatePresence>
|
|
{showHowItWorks && (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 rounded-xl p-4 mb-4"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Info className="w-5 h-5 text-blue-600" />
|
|
<h3 className="font-bold text-blue-900">How it works</h3>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
|
<GripVertical className="w-4 h-4" />
|
|
<strong>Drag</strong> widgets to reorder them
|
|
</p>
|
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
|
<EyeOff className="w-4 h-4" />
|
|
<strong>Hide</strong> widgets you don't need
|
|
</p>
|
|
<p className="text-sm text-blue-800 flex items-center gap-2">
|
|
<Eye className="w-4 h-4" />
|
|
<strong>Show</strong> hidden widgets to bring them back
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowHowItWorks(false)}
|
|
className="text-blue-400 hover:text-blue-600 transition-colors"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="space-y-6">
|
|
{/* Visible Widgets */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="font-bold text-lg text-slate-900">
|
|
Visible Widgets ({visibleWidgets.length})
|
|
</h3>
|
|
<Button
|
|
onClick={handleReset}
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2"
|
|
disabled={saveLayoutMutation.isPending}
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
Reset to Default
|
|
</Button>
|
|
</div>
|
|
|
|
<DragDropContext onDragEnd={handleDragEnd}>
|
|
<Droppable droppableId="visible">
|
|
{(provided, snapshot) => (
|
|
<div
|
|
{...provided.droppableProps}
|
|
ref={provided.innerRef}
|
|
className={`space-y-2 min-h-[100px] p-4 rounded-lg border-2 border-dashed transition-all ${
|
|
snapshot.isDraggingOver
|
|
? 'border-blue-400 bg-blue-50'
|
|
: 'border-slate-200 bg-slate-50'
|
|
}`}
|
|
>
|
|
{visibleWidgets.length === 0 ? (
|
|
<div className="text-center py-12 text-slate-400">
|
|
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm font-medium">No visible widgets</p>
|
|
<p className="text-xs mt-1">Add widgets from the hidden section below!</p>
|
|
</div>
|
|
) : (
|
|
visibleWidgets.map((widget, index) => (
|
|
<Draggable key={widget.id} draggableId={widget.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
className={`bg-white border-2 rounded-lg p-4 transition-all ${
|
|
snapshot.isDragging
|
|
? 'border-blue-400 shadow-2xl scale-105 rotate-2'
|
|
: 'border-slate-200 hover:border-blue-300 hover:shadow-md'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
{...provided.dragHandleProps}
|
|
className="cursor-grab active:cursor-grabbing text-slate-400 hover:text-blue-600 transition-colors p-1 hover:bg-blue-50 rounded"
|
|
>
|
|
<GripVertical className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="font-bold text-slate-900">{widget.title}</p>
|
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
|
</div>
|
|
<Badge className={`${widget.categoryColor || 'bg-blue-100 text-blue-700'} border-0 text-xs`}>
|
|
{widget.category}
|
|
</Badge>
|
|
<button
|
|
onClick={() => handleHideWidget(widget)}
|
|
className="text-slate-400 hover:text-red-600 transition-colors p-2 hover:bg-red-50 rounded-lg"
|
|
title="Hide widget"
|
|
disabled={saveLayoutMutation.isPending}
|
|
>
|
|
<EyeOff className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
))
|
|
)}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
</DragDropContext>
|
|
</div>
|
|
|
|
{/* Hidden Widgets */}
|
|
{hiddenWidgets.length > 0 && (
|
|
<div>
|
|
<h3 className="font-bold text-lg text-slate-900 mb-3 flex items-center gap-2">
|
|
Hidden Widgets ({hiddenWidgets.length})
|
|
<Badge className="bg-slate-200 text-slate-600 text-xs">Click + to add</Badge>
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{hiddenWidgets.map((widget) => (
|
|
<div
|
|
key={widget.id}
|
|
className="bg-slate-50 border-2 border-dashed border-slate-300 rounded-lg p-4 opacity-60 hover:opacity-100 transition-all hover:border-green-400 hover:bg-green-50/50 group"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1">
|
|
<p className="font-semibold text-slate-900">{widget.title}</p>
|
|
<p className="text-xs text-slate-500 mt-0.5">{widget.description}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleShowWidget(widget)}
|
|
className="text-slate-400 hover:text-green-600 group-hover:bg-green-100 transition-colors p-2 rounded-lg"
|
|
title="Show widget"
|
|
disabled={saveLayoutMutation.isPending}
|
|
>
|
|
<Plus className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* All Hidden Message */}
|
|
{hiddenWidgets.length === 0 && visibleWidgets.length === availableWidgets.length && (
|
|
<div className="text-center py-6 bg-green-50 border-2 border-green-200 rounded-lg">
|
|
<Sparkles className="w-8 h-8 mx-auto mb-2 text-green-600" />
|
|
<p className="text-sm font-medium text-green-800">
|
|
All widgets are visible on your dashboard!
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between pt-4 border-t mt-6">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={handleClose}
|
|
className="text-slate-600"
|
|
disabled={saveLayoutMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{hasChanges && (
|
|
<Badge className="bg-orange-500 text-white animate-pulse">
|
|
Unsaved Changes
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowHowItWorks(!showHowItWorks)}
|
|
className="gap-2"
|
|
disabled={saveLayoutMutation.isPending}
|
|
>
|
|
<Info className="w-4 h-4" />
|
|
{showHowItWorks ? 'Hide' : 'Show'} Help
|
|
</Button>
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saveLayoutMutation.isPending || !hasChanges}
|
|
className="bg-blue-600 hover:bg-blue-700 gap-2 disabled:opacity-50"
|
|
>
|
|
<Save className="w-4 h-4" />
|
|
{saveLayoutMutation.isPending ? "Saving..." : hasChanges ? "Save Layout" : "No Changes"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
} |