Files
Krow-workspace/frontend-web-free/src/components/dashboard/DashboardCustomizer.jsx
2025-12-04 18:02:28 -05:00

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>
</>
);
}