From 540b250a470346c0e9e74d82bf338efb14bb2228 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Tue, 20 Jan 2026 16:36:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20adiciona=20widgets=20mov?= =?UTF-8?q?=C3=ADveis=20e=20ocult=C3=A1veis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../dashboard/dashboard-grid-editable.tsx | 287 ++++++++++++++++++ components/dashboard/sortable-widget.tsx | 47 +++ .../dashboard/widget-settings-dialog.tsx | 92 ++++++ lib/dashboard/widgets/actions.ts | 87 ++++++ 4 files changed, 513 insertions(+) create mode 100644 components/dashboard/dashboard-grid-editable.tsx create mode 100644 components/dashboard/sortable-widget.tsx create mode 100644 components/dashboard/widget-settings-dialog.tsx create mode 100644 lib/dashboard/widgets/actions.ts diff --git a/components/dashboard/dashboard-grid-editable.tsx b/components/dashboard/dashboard-grid-editable.tsx new file mode 100644 index 0000000..9b9aa30 --- /dev/null +++ b/components/dashboard/dashboard-grid-editable.tsx @@ -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( + initialPreferences?.order ?? defaultOrder, + ); + const [hiddenWidgets, setHiddenWidgets] = useState( + 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 ( +
+ {/* Toolbar */} +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + {/* Grid */} + + w.id)} + strategy={rectSortingStrategy} + > +
+ {orderedWidgets.map((widget) => ( + +
+ {isEditing && ( +
+
+ + + Arraste para mover + + +
+
+ )} + + {widget.component({ data, period })} + +
+
+ ))} +
+
+
+ + {/* Hidden widgets indicator */} + {hiddenWidgets.length > 0 && !isEditing && ( +

+ {hiddenWidgets.length} widget(s) oculto(s) •{" "} + +

+ )} +
+ ); +} diff --git a/components/dashboard/sortable-widget.tsx b/components/dashboard/sortable-widget.tsx new file mode 100644 index 0000000..4b00f47 --- /dev/null +++ b/components/dashboard/sortable-widget.tsx @@ -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 ( +
+ {children} +
+ ); +} diff --git a/components/dashboard/widget-settings-dialog.tsx b/components/dashboard/widget-settings-dialog.tsx new file mode 100644 index 0000000..4566e1f --- /dev/null +++ b/components/dashboard/widget-settings-dialog.tsx @@ -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 ( + + + + + + + Configurar Widgets + + Escolha quais widgets deseja exibir no seu dashboard. + + + +
+
+ {widgetsConfig.map((widget) => { + const isVisible = !hiddenWidgets.includes(widget.id); + + return ( +
+
+ {widget.icon} +
+

+ {widget.title} +

+

+ {widget.subtitle} +

+
+
+ onToggleWidget(widget.id)} + /> +
+ ); + })} +
+
+ + + + +
+
+ ); +} diff --git a/lib/dashboard/widgets/actions.ts b/lib/dashboard/widgets/actions.ts new file mode 100644 index 0000000..2454d9e --- /dev/null +++ b/lib/dashboard/widgets/actions.ts @@ -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 { + 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; + } +}