feat(dashboard): adiciona widgets movíveis e ocultáveis
- Cria SortableWidget com @dnd-kit para drag-and-drop - Cria DashboardGridEditable com modo de edição - Cria WidgetSettingsDialog para gerenciar visibilidade - Cria server actions para persistir preferências
This commit is contained in:
287
components/dashboard/dashboard-grid-editable.tsx
Normal file
287
components/dashboard/dashboard-grid-editable.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SortableWidget } from "@/components/dashboard/sortable-widget";
|
||||||
|
import { WidgetSettingsDialog } from "@/components/dashboard/widget-settings-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import WidgetCard from "@/components/widget-card";
|
||||||
|
import type { DashboardData } from "@/lib/dashboard/fetch-dashboard-data";
|
||||||
|
import {
|
||||||
|
resetWidgetPreferences,
|
||||||
|
updateWidgetPreferences,
|
||||||
|
type WidgetPreferences,
|
||||||
|
} from "@/lib/dashboard/widgets/actions";
|
||||||
|
import {
|
||||||
|
widgetsConfig,
|
||||||
|
type WidgetConfig,
|
||||||
|
} from "@/lib/dashboard/widgets/widgets-config";
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
rectSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import {
|
||||||
|
RiCheckLine,
|
||||||
|
RiCloseLine,
|
||||||
|
RiDragMove2Line,
|
||||||
|
RiEyeOffLine,
|
||||||
|
} from "@remixicon/react";
|
||||||
|
import { useCallback, useMemo, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type DashboardGridEditableProps = {
|
||||||
|
data: DashboardData;
|
||||||
|
period: string;
|
||||||
|
initialPreferences: WidgetPreferences | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DashboardGridEditable({
|
||||||
|
data,
|
||||||
|
period,
|
||||||
|
initialPreferences,
|
||||||
|
}: DashboardGridEditableProps) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Initialize widget order and hidden state
|
||||||
|
const defaultOrder = widgetsConfig.map((w) => w.id);
|
||||||
|
const [widgetOrder, setWidgetOrder] = useState<string[]>(
|
||||||
|
initialPreferences?.order ?? defaultOrder,
|
||||||
|
);
|
||||||
|
const [hiddenWidgets, setHiddenWidgets] = useState<string[]>(
|
||||||
|
initialPreferences?.hidden ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep track of original state for cancel
|
||||||
|
const [originalOrder, setOriginalOrder] = useState(widgetOrder);
|
||||||
|
const [originalHidden, setOriginalHidden] = useState(hiddenWidgets);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get ordered and visible widgets
|
||||||
|
const orderedWidgets = useMemo(() => {
|
||||||
|
// Create a map for quick lookup
|
||||||
|
const widgetMap = new Map(widgetsConfig.map((w) => [w.id, w]));
|
||||||
|
|
||||||
|
// Get widgets in order, filtering out hidden ones
|
||||||
|
const ordered: WidgetConfig[] = [];
|
||||||
|
for (const id of widgetOrder) {
|
||||||
|
const widget = widgetMap.get(id);
|
||||||
|
if (widget && !hiddenWidgets.includes(id)) {
|
||||||
|
ordered.push(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any new widgets that might not be in the order yet
|
||||||
|
for (const widget of widgetsConfig) {
|
||||||
|
if (
|
||||||
|
!widgetOrder.includes(widget.id) &&
|
||||||
|
!hiddenWidgets.includes(widget.id)
|
||||||
|
) {
|
||||||
|
ordered.push(widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}, [widgetOrder, hiddenWidgets]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setWidgetOrder((items) => {
|
||||||
|
const oldIndex = items.indexOf(active.id as string);
|
||||||
|
const newIndex = items.indexOf(over.id as string);
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleWidget = useCallback((widgetId: string) => {
|
||||||
|
setHiddenWidgets((prev) =>
|
||||||
|
prev.includes(widgetId)
|
||||||
|
? prev.filter((id) => id !== widgetId)
|
||||||
|
: [...prev, widgetId],
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleHideWidget = useCallback((widgetId: string) => {
|
||||||
|
setHiddenWidgets((prev) => [...prev, widgetId]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartEditing = useCallback(() => {
|
||||||
|
setOriginalOrder(widgetOrder);
|
||||||
|
setOriginalHidden(hiddenWidgets);
|
||||||
|
setIsEditing(true);
|
||||||
|
}, [widgetOrder, hiddenWidgets]);
|
||||||
|
|
||||||
|
const handleCancelEditing = useCallback(() => {
|
||||||
|
setWidgetOrder(originalOrder);
|
||||||
|
setHiddenWidgets(originalHidden);
|
||||||
|
setIsEditing(false);
|
||||||
|
}, [originalOrder, originalHidden]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateWidgetPreferences({
|
||||||
|
order: widgetOrder,
|
||||||
|
hidden: hiddenWidgets,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success("Preferências salvas!");
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Erro ao salvar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [widgetOrder, hiddenWidgets]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await resetWidgetPreferences();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setWidgetOrder(defaultOrder);
|
||||||
|
setHiddenWidgets([]);
|
||||||
|
toast.success("Preferências restauradas!");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error ?? "Erro ao restaurar");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [defaultOrder]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelEditing}
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiCloseLine className="size-4" />
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isPending}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiCheckLine className="size-4" />
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WidgetSettingsDialog
|
||||||
|
hiddenWidgets={hiddenWidgets}
|
||||||
|
onToggleWidget={handleToggleWidget}
|
||||||
|
onReset={handleReset}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiDragMove2Line className="size-4" />
|
||||||
|
Reordenar
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={orderedWidgets.map((w) => w.id)}
|
||||||
|
strategy={rectSortingStrategy}
|
||||||
|
>
|
||||||
|
<section className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
|
||||||
|
{orderedWidgets.map((widget) => (
|
||||||
|
<SortableWidget
|
||||||
|
key={widget.id}
|
||||||
|
id={widget.id}
|
||||||
|
isEditing={isEditing}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{isEditing && (
|
||||||
|
<div className="absolute inset-0 z-10 bg-background/50 backdrop-blur-[1px] rounded-lg border-2 border-dashed border-primary/50 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<RiDragMove2Line className="size-8 text-primary" />
|
||||||
|
<span className="text-xs font-bold">
|
||||||
|
Arraste para mover
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleHideWidget(widget.id);
|
||||||
|
}}
|
||||||
|
className="gap-1 mt-2"
|
||||||
|
>
|
||||||
|
<RiEyeOffLine className="size-4" />
|
||||||
|
Ocultar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<WidgetCard
|
||||||
|
title={widget.title}
|
||||||
|
subtitle={widget.subtitle}
|
||||||
|
icon={widget.icon}
|
||||||
|
action={widget.action}
|
||||||
|
>
|
||||||
|
{widget.component({ data, period })}
|
||||||
|
</WidgetCard>
|
||||||
|
</div>
|
||||||
|
</SortableWidget>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* Hidden widgets indicator */}
|
||||||
|
{hiddenWidgets.length > 0 && !isEditing && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{hiddenWidgets.length} widget(s) oculto(s) •{" "}
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Restaurar todos
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
components/dashboard/sortable-widget.tsx
Normal file
47
components/dashboard/sortable-widget.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type SortableWidgetProps = {
|
||||||
|
id: string;
|
||||||
|
children: ReactNode;
|
||||||
|
isEditing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SortableWidget({
|
||||||
|
id,
|
||||||
|
children,
|
||||||
|
isEditing,
|
||||||
|
}: SortableWidgetProps) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id, disabled: !isEditing });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
isDragging && "z-50 opacity-90",
|
||||||
|
isEditing && "cursor-grab active:cursor-grabbing",
|
||||||
|
)}
|
||||||
|
{...(isEditing ? { ...attributes, ...listeners } : {})}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
components/dashboard/widget-settings-dialog.tsx
Normal file
92
components/dashboard/widget-settings-dialog.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { widgetsConfig } from "@/lib/dashboard/widgets/widgets-config";
|
||||||
|
import { RiRefreshLine, RiSettings4Line } from "@remixicon/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type WidgetSettingsDialogProps = {
|
||||||
|
hiddenWidgets: string[];
|
||||||
|
onToggleWidget: (widgetId: string) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WidgetSettingsDialog({
|
||||||
|
hiddenWidgets,
|
||||||
|
onToggleWidget,
|
||||||
|
onReset,
|
||||||
|
}: WidgetSettingsDialogProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<RiSettings4Line className="size-4" />
|
||||||
|
Widgets
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configurar Widgets</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Escolha quais widgets deseja exibir no seu dashboard.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] overflow-y-auto py-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{widgetsConfig.map((widget) => {
|
||||||
|
const isVisible = !hiddenWidgets.includes(widget.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={widget.id}
|
||||||
|
className="flex items-center justify-between gap-4 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-primary shrink-0">{widget.icon}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{widget.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{widget.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={() => onToggleWidget(widget.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onReset}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RiRefreshLine className="size-4" />
|
||||||
|
Restaurar Padrão
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
lib/dashboard/widgets/actions.ts
Normal file
87
lib/dashboard/widgets/actions.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getUser } from "@/lib/auth/server";
|
||||||
|
import { db, schema } from "@/lib/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export type WidgetPreferences = {
|
||||||
|
order: string[];
|
||||||
|
hidden: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function updateWidgetPreferences(
|
||||||
|
preferences: WidgetPreferences,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
// Check if preferences exist
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.userPreferences.id })
|
||||||
|
.from(schema.userPreferences)
|
||||||
|
.where(eq(schema.userPreferences.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(schema.userPreferences)
|
||||||
|
.set({
|
||||||
|
dashboardWidgets: preferences,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.userPreferences.userId, user.id));
|
||||||
|
} else {
|
||||||
|
await db.insert(schema.userPreferences).values({
|
||||||
|
userId: user.id,
|
||||||
|
dashboardWidgets: preferences,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating widget preferences:", error);
|
||||||
|
return { success: false, error: "Erro ao salvar preferências" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetWidgetPreferences(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.userPreferences)
|
||||||
|
.set({
|
||||||
|
dashboardWidgets: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.userPreferences.userId, user.id));
|
||||||
|
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting widget preferences:", error);
|
||||||
|
return { success: false, error: "Erro ao resetar preferências" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWidgetPreferences(): Promise<WidgetPreferences | null> {
|
||||||
|
try {
|
||||||
|
const user = await getUser();
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({ dashboardWidgets: schema.userPreferences.dashboardWidgets })
|
||||||
|
.from(schema.userPreferences)
|
||||||
|
.where(eq(schema.userPreferences.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result[0]?.dashboardWidgets ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting widget preferences:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user