From 1b32638eb542f1cd7947a2ddf62f0a2a4b80281f Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Thu, 6 Nov 2025 11:21:40 -0300 Subject: [PATCH] fix: ajustes dashboards tv e titulos --- README.md | 30 +- PROXIMOS_PASSOS.md => docs/PROXIMOS_PASSOS.md | 0 .../dashboards/[id]/export/[format]/route.ts | 176 ++++++++ src/app/api/export/pdf/route.ts | 95 ----- .../dashboards/dashboard-builder.tsx | 386 ++++++++++++++---- src/components/dashboards/dashboard-list.tsx | 81 +++- src/components/dashboards/widget-renderer.tsx | 8 +- src/lib/auth.ts | 10 +- src/lib/prisma.ts | 55 +-- 9 files changed, 609 insertions(+), 232 deletions(-) rename PROXIMOS_PASSOS.md => docs/PROXIMOS_PASSOS.md (100%) create mode 100644 src/app/api/dashboards/[id]/export/[format]/route.ts delete mode 100644 src/app/api/export/pdf/route.ts diff --git a/README.md b/README.md index 638ed3c..dc5db70 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Aplicação **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better ``` 2. Ajuste o arquivo `.env` (ou crie a partir de `.env.example`) e confirme os valores de: - `NEXT_PUBLIC_CONVEX_URL` (gerado pelo Convex Dev) - - `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` + - `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `DATABASE_URL` (por padrão `file:./db.dev.sqlite`, que mapeia para `prisma/db.dev.sqlite`) 3. Aplique as migrações e gere o client Prisma: ```bash bunx prisma migrate deploy @@ -26,21 +26,27 @@ Aplicação **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better ```bash bun run auth:seed ``` -5. (Opcional) Para re-sincronizar manualmente as filas padrão, execute: + > Sempre que trocar de máquina ou quiser “zerar” o ambiente local, basta repetir os passos 3 e 4 com a mesma `DATABASE_URL`. + +### Resetar rapidamente o ambiente local + +1. Garanta que `DATABASE_URL` aponte para o arquivo desejado (ex.: `file:./db.dev.sqlite` para desenvolvimento, `file:./db.sqlite` em produção local). +2. Aplique as migrações no arquivo informado: ```bash - bun run queues:ensure + DATABASE_URL=file:./db.dev.sqlite bunx prisma migrate deploy ``` -6. Em um terminal, execute o backend em tempo real do Convex: +3. Recrie/garanta as contas padrão de login: ```bash - bun run convex:dev:bun + DATABASE_URL=file:./db.dev.sqlite bun run auth:seed ``` - > Alternativa: `bun run convex:dev` (runtime Node) caso queira manter o comportamento anterior. -7. Em outro terminal, suba o frontend Next.js (dev com Turbopack): - ```bash - bun run dev:bun - ``` - > Fallback: `bun run dev:webpack` caso o Turbopack acione alguma incompatibilidade. -8. Com o Convex ativo, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários) diretamente no banco do Convex. +4. Suba o servidor normalmente com `bun run dev`. Esses três comandos bastam para reconstruir o ambiente sempre que trocar de computador. + +### Subir serviços locais + +- (Opcional) Para re-sincronizar manualmente as filas padrão, execute `bun run queues:ensure`. +- Em um terminal, rode o backend em tempo real do Convex com `bun run convex:dev:bun` (ou `bun run convex:dev` para o runtime Node). +- Em outro terminal, suba o frontend Next.js (Turpoback) com `bun run dev:bun` (`bun run dev:webpack` serve como fallback). +- Com o Convex rodando, acesse `http://localhost:3000/dev/seed` uma vez para popular dados de demonstração (tickets, usuários, comentários). > Se o CLI perguntar sobre configuração do projeto Convex, escolha criar um novo deployment local (opção padrão) e confirme. As credenciais são armazenadas em `.convex/` automaticamente. diff --git a/PROXIMOS_PASSOS.md b/docs/PROXIMOS_PASSOS.md similarity index 100% rename from PROXIMOS_PASSOS.md rename to docs/PROXIMOS_PASSOS.md diff --git a/src/app/api/dashboards/[id]/export/[format]/route.ts b/src/app/api/dashboards/[id]/export/[format]/route.ts new file mode 100644 index 0000000..f6de355 --- /dev/null +++ b/src/app/api/dashboards/[id]/export/[format]/route.ts @@ -0,0 +1,176 @@ +import { NextResponse } from "next/server" +import type { Browser } from "playwright" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { createConvexClient } from "@/server/convex-client" + +export const runtime = "nodejs" + +const WAIT_SELECTOR_DEFAULT = "[data-dashboard-ready='true']" +const DEFAULT_VIEWPORT_WIDTH = 1600 +const DEFAULT_VIEWPORT_HEIGHT = 900 + +function slugifyFilename(raw: string, extension: "pdf" | "png") { + const base = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-zA-Z0-9-_]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + const safe = base.length > 0 ? base : "dashboard" + return `${safe}.${extension}` +} + +type ExportFormat = "pdf" | "png" + +function isSupportedFormat(value: string): value is ExportFormat { + return value === "pdf" || value === "png" +} + +function buildDisposition(filename: string) { + const encoded = encodeURIComponent(filename) + return `attachment; filename="${filename}"; filename*=UTF-8''${encoded}` +} + +export async function GET(request: Request, { params }: { params: { id: string; format: string } }) { + const formatParam = params.format?.toLowerCase() + if (!isSupportedFormat(formatParam)) { + return NextResponse.json({ error: "Formato não suportado" }, { status: 400 }) + } + + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convex = createConvexClient() + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let ensuredUser: { _id: Id<"users"> } | null = null + try { + ensuredUser = await convex.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + } catch (error) { + console.error("[dashboards.export] Falha ao garantir usuário no Convex", error) + return NextResponse.json({ error: "Não foi possível preparar a exportação" }, { status: 500 }) + } + + const viewerId = ensuredUser?._id + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + let detail: { dashboard: { name?: string | null; readySelector?: string | null } | null } | null = null + try { + detail = await convex.query(api.dashboards.get, { + tenantId, + viewerId, + dashboardId: params.id as Id<"dashboards">, + }) + } catch (error) { + console.error("[dashboards.export] Falha ao obter dashboard", error, { tenantId, dashboardId: params.id }) + return NextResponse.json({ error: "Não foi possível carregar o dashboard" }, { status: 500 }) + } + + if (!detail?.dashboard) { + return NextResponse.json({ error: "Dashboard não encontrado" }, { status: 404 }) + } + + const requestUrl = new URL(request.url) + const printUrl = new URL(`/dashboards/${params.id}/print`, requestUrl.origin).toString() + const waitForSelector = detail.dashboard.readySelector ?? WAIT_SELECTOR_DEFAULT + const width = Number(requestUrl.searchParams.get("width") ?? DEFAULT_VIEWPORT_WIDTH) || DEFAULT_VIEWPORT_WIDTH + const height = Number(requestUrl.searchParams.get("height") ?? DEFAULT_VIEWPORT_HEIGHT) || DEFAULT_VIEWPORT_HEIGHT + let browser: Browser | null = null + + try { + const { chromium } = await import("playwright") + try { + browser = await chromium.launch({ headless: true }) + } catch (launchError) { + const message = (launchError as Error)?.message ?? String(launchError) + if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(message)) { + return NextResponse.json( + { + error: "Playwright browsers not installed", + hint: "Execute: npx playwright install", + details: message, + }, + { status: 501 }, + ) + } + throw launchError + } + + const page = await browser.newPage({ viewport: { width, height } }) + await page.goto(printUrl, { waitUntil: "networkidle" }) + if (waitForSelector) { + try { + await page.waitForSelector(waitForSelector, { timeout: 15000 }) + } catch (error) { + console.warn("[dashboards.export] Tempo excedido aguardando seletor", waitForSelector, error) + } + } + + await page.emulateMedia({ media: "screen" }).catch(() => undefined) + + if (formatParam === "pdf") { + const scrollHeight = await page.evaluate(() => + Math.max( + document.documentElement?.scrollHeight ?? 0, + document.body?.scrollHeight ?? 0, + window.innerHeight, + ), + ) + const pdfBuffer = await page.pdf({ + width: `${width}px`, + height: `${Math.max(scrollHeight, height)}px`, + printBackground: true, + margin: { + top: "12px", + bottom: "12px", + left: "18px", + right: "18px", + }, + }) + const bytes = pdfBuffer instanceof Uint8Array ? pdfBuffer : new Uint8Array(pdfBuffer) + const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "pdf") + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": buildDisposition(filename), + "Cache-Control": "no-store", + }, + }) + } + + const screenshot = await page.screenshot({ type: "png", fullPage: true }) + const bytes = screenshot instanceof Uint8Array ? screenshot : new Uint8Array(screenshot) + const filename = slugifyFilename(detail.dashboard.name ?? "dashboard", "png") + return new NextResponse(bytes, { + status: 200, + headers: { + "Content-Type": "image/png", + "Content-Disposition": buildDisposition(filename), + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("[dashboards.export] Falha ao exportar dashboard", error) + return NextResponse.json({ error: "Falha ao exportar o dashboard" }, { status: 500 }) + } finally { + if (browser) { + await browser.close().catch(() => undefined) + } + } +} diff --git a/src/app/api/export/pdf/route.ts b/src/app/api/export/pdf/route.ts deleted file mode 100644 index ed66457..0000000 --- a/src/app/api/export/pdf/route.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { NextResponse } from "next/server" - -export const runtime = "nodejs" - -type ExportRequest = { - url?: string - width?: number - height?: number - format?: "pdf" | "png" - waitForSelector?: string -} - -export async function POST(request: Request) { - let payload: ExportRequest - try { - payload = await request.json() - } catch (error) { - return NextResponse.json({ error: "Payload inválido" }, { status: 400 }) - } - - if (!payload.url) { - return NextResponse.json({ error: "URL obrigatória" }, { status: 400 }) - } - - const { chromium } = await import("playwright") - const width = payload.width ?? 1920 - const height = payload.height ?? 1080 - const format = payload.format ?? "pdf" - const waitForSelector = payload.waitForSelector ?? "[data-dashboard-ready='true']" - - let browser: Awaited> | null = null - - try { - try { - browser = await chromium.launch({ headless: true }) - } catch (launchError) { - const msg = (launchError as Error)?.message || String(launchError) - // Dev-friendly response when Playwright browsers are missing - if (/Executable doesn't exist|Please run the following command to download new browsers/i.test(msg)) { - return NextResponse.json( - { - error: "Playwright browsers not installed", - hint: "Execute: npx playwright install", - details: msg, - }, - { status: 501 }, - ) - } - throw launchError - } - const page = await browser.newPage({ viewport: { width, height } }) - await page.goto(payload.url, { waitUntil: "networkidle" }) - if (waitForSelector) { - try { - await page.waitForSelector(waitForSelector, { timeout: 15000 }) - } catch (error) { - console.warn("waitForSelector timeout", error) - } - } - - if (format === "pdf") { - const pdf = await page.pdf({ - width: `${width}px`, - height: `${height}px`, - printBackground: true, - pageRanges: "1", - }) - const pdfBytes = new Uint8Array(pdf) - return new NextResponse(pdfBytes, { - status: 200, - headers: { - "Content-Type": "application/pdf", - "Content-Disposition": "attachment; filename=dashboard.pdf", - }, - }) - } - - const screenshot = await page.screenshot({ type: "png", fullPage: true }) - const screenshotBytes = new Uint8Array(screenshot) - return new NextResponse(screenshotBytes, { - status: 200, - headers: { - "Content-Type": "image/png", - "Content-Disposition": "attachment; filename=dashboard.png", - }, - }) - } catch (error) { - console.error("Failed to export dashboard", error) - return NextResponse.json({ error: "Falha ao exportar o dashboard" }, { status: 500 }) - } finally { - if (browser) { - await browser.close() - } - } -} diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx index 35c18df..8b51bb2 100644 --- a/src/components/dashboards/dashboard-builder.tsx +++ b/src/components/dashboards/dashboard-builder.tsx @@ -71,6 +71,7 @@ import { SearchableCombobox, type SearchableComboboxOption, } from "@/components/ui/searchable-combobox" +import { useSidebar } from "@/components/ui/sidebar" import { toast } from "sonner" import { z } from "zod" import { useForm } from "react-hook-form" @@ -530,6 +531,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const tvQuery = searchParams?.get("tv") const enforceTv = tvQuery === "1" || mode === "tv" const { session, convexUserId, isStaff } = useAuth() + const { open, setOpen, openMobile, setOpenMobile, isMobile } = useSidebar() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const viewerId = convexUserId as Id<"users"> | null const canEdit = editable && Boolean(viewerId) && isStaff @@ -561,6 +563,11 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const [dataTarget, setDataTarget] = useState(null) const [isAddingWidget, setIsAddingWidget] = useState(false) const [isExporting, setIsExporting] = useState(false) + const [isFullscreen, setIsFullscreen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [isDeletingDashboard, setIsDeletingDashboard] = useState(false) + const fullscreenContainerRef = useRef(null) + const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null) const updateLayoutMutation = useMutation(api.dashboards.updateLayout) const updateFiltersMutation = useMutation(api.dashboards.updateFilters) @@ -569,6 +576,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const duplicateWidgetMutation = useMutation(api.dashboards.duplicateWidget) const removeWidgetMutation = useMutation(api.dashboards.removeWidget) const updateMetadataMutation = useMutation(api.dashboards.updateMetadata) + const archiveDashboardMutation = useMutation(api.dashboards.archive) useEffect(() => { if (!detail) return @@ -588,10 +596,78 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } filtersHydratingRef.current = false }, [detail, filters]) + const sections = useMemo(() => { + if (dashboard?.sections && dashboard.sections.length > 0) { + return dashboard.sections + } + if (!widgets.length) return [] + const chunkSize = 4 + const autoSections: DashboardSection[] = [] + widgets.forEach((widget, index) => { + const bucket = Math.floor(index / chunkSize) + if (!autoSections[bucket]) { + autoSections[bucket] = { + id: `auto-${bucket}`, + title: `Slide ${bucket + 1}`, + description: null, + widgetKeys: [], + durationSeconds: undefined, + } + } + autoSections[bucket]?.widgetKeys.push(widget.widgetKey) + }) + return autoSections + }, [dashboard?.sections, widgets]) + useEffect(() => { layoutRef.current = layoutState }, [layoutState]) + useEffect(() => { + if (sections.length === 0) { + setActiveSectionIndex(0) + } else { + setActiveSectionIndex((index) => Math.min(index, sections.length - 1)) + } + }, [sections.length]) + + useEffect(() => { + const handleFullscreenChange = () => { + const currentlyFullscreen = Boolean(document.fullscreenElement) + setIsFullscreen(currentlyFullscreen) + if (!currentlyFullscreen && previousSidebarStateRef.current) { + const previous = previousSidebarStateRef.current + setOpen(previous.open) + setOpenMobile(previous.openMobile) + previousSidebarStateRef.current = null + } + } + document.addEventListener("fullscreenchange", handleFullscreenChange) + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange) + }, [setOpen, setOpenMobile]) + + const handleToggleFullscreen = useCallback(async () => { + if (typeof document === "undefined") return + try { + if (!document.fullscreenElement) { + previousSidebarStateRef.current = { open, openMobile } + if (isMobile) { + setOpenMobile(false) + } else { + setOpen(false) + } + const target = fullscreenContainerRef.current ?? document.documentElement + if (target && target.requestFullscreen) { + await target.requestFullscreen() + } + } else if (document.exitFullscreen) { + await document.exitFullscreen() + } + } catch (error) { + console.error("[dashboards] Failed to toggle fullscreen", error) + } + }, [isMobile, open, openMobile, setOpen, setOpenMobile]) + const packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState]) const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole]) @@ -602,8 +678,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } return map }, [widgets]) - const sections = useMemo(() => dashboard?.sections ?? [], [dashboard?.sections]) - useEffect(() => { if (!enforceTv || sections.length <= 1) return const intervalSeconds = dashboard?.tvIntervalSeconds && dashboard.tvIntervalSeconds > 0 ? dashboard.tvIntervalSeconds : 30 @@ -916,35 +990,55 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } } } + const handleDeleteDashboard = async () => { + if (!dashboard || !viewerId) return + setIsDeletingDashboard(true) + try { + await archiveDashboardMutation({ + tenantId, + actorId: viewerId as Id<"users">, + dashboardId: dashboard.id, + archived: true, + }) + toast.success("Dashboard removido.") + router.push("/dashboards") + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o dashboard.") + } finally { + setIsDeletingDashboard(false) + setIsDeleteDialogOpen(false) + } + } + const handleExport = async (format: "pdf" | "png") => { if (!dashboard) return setIsExporting(true) try { - const response = await fetch("/api/export/pdf", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - url: `${window.location.origin}/dashboards/${dashboard.id}/print`, - format, - width: 1920, - height: 1080, - waitForSelector: dashboard.readySelector ?? "[data-dashboard-ready='true']", - }), - }) + const response = await fetch(`/api/dashboards/${dashboard.id}/export/${format}`) if (!response.ok) { - let msg = "Export request failed" - try { - const data = await response.json() - if (data?.hint) msg = `${msg}: ${data.hint}` - else if (data?.error) msg = `${msg}: ${data.error}` - } catch {} - throw new Error(msg) + let message = `Export request failed (${response.status})` + if (response.headers.get("content-type")?.includes("application/json")) { + try { + const data = await response.json() + if (data?.hint) message = data.hint + else if (data?.error) message = data.error + } catch { + // ignore parse errors + } + } + throw new Error(message) } const blob = await response.blob() const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url - link.download = `${dashboard.name ?? "dashboard"}.${format === "pdf" ? "pdf" : "png"}` + const disposition = response.headers.get("content-disposition") + const fallbackName = `${dashboard.name ?? "dashboard"}.${format === "pdf" ? "pdf" : "png"}` + const parsedName = disposition?.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/) + const decoded = + parsedName?.[1] ? decodeURIComponent(parsedName[1]) : parsedName?.[2] ?? fallbackName + link.download = decoded || fallbackName link.click() URL.revokeObjectURL(url) toast.success("Exportação gerada com sucesso!") @@ -993,29 +1087,57 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const visibleCount = canvasItems.length return ( -
- +
+ {isFullscreen ? ( +
+ +
+ ) : null} - + {!isFullscreen ? ( + setIsDeleteDialogOpen(true)} + isFullscreen={isFullscreen} + onToggleFullscreen={handleToggleFullscreen} + /> + ) : null} + + {!isFullscreen ? : null} {enforceTv && sections.length > 0 ? ( ) : null} @@ -1043,16 +1165,20 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } ) : null} - + {enforceTv ? ( + + ) : ( + + )} {canEdit && !enforceTv ? ( + + + +
) } @@ -1101,6 +1258,9 @@ function BuilderHeader({ totalSections, onToggleTvMode, totalWidgets, + onDeleteRequest, + isFullscreen = false, + onToggleFullscreen, }: { dashboard: DashboardRecord canEdit: boolean @@ -1114,6 +1274,9 @@ function BuilderHeader({ totalSections: number onToggleTvMode: () => void totalWidgets: number + onDeleteRequest?: () => void + isFullscreen?: boolean + onToggleFullscreen: () => void }) { const [name, setName] = useState(dashboard.name) const [description, setDescription] = useState(dashboard.description ?? "") @@ -1121,7 +1284,6 @@ function BuilderHeader({ const [draftName, setDraftName] = useState(dashboard.name) const [draftDescription, setDraftDescription] = useState(dashboard.description ?? "") const [isSavingHeader, setIsSavingHeader] = useState(false) - const [isFullscreen, setIsFullscreen] = useState(false) useEffect(() => { setName(dashboard.name) @@ -1132,34 +1294,11 @@ function BuilderHeader({ } }, [dashboard.name, dashboard.description, isEditingHeader]) - useEffect(() => { - if (typeof document === "undefined") return - const handleFullscreenChange = () => { - setIsFullscreen(Boolean(document.fullscreenElement)) - } - document.addEventListener("fullscreenchange", handleFullscreenChange) - handleFullscreenChange() - return () => { - document.removeEventListener("fullscreenchange", handleFullscreenChange) - } - }, []) - - const toggleFullscreen = useCallback(() => { - if (typeof document === "undefined") return - if (document.fullscreenElement) { - const exit = document.exitFullscreen - if (typeof exit === "function") { - exit.call(document).catch(() => undefined) - } - return - } - const element = document.documentElement - if (element && typeof element.requestFullscreen === "function") { - element.requestFullscreen().catch(() => undefined) - } - }, []) - const rotationInterval = Math.max(5, dashboard.tvIntervalSeconds ?? 30) + const themeLabel = + typeof dashboard.theme === "string" && dashboard.theme.trim().length > 0 && dashboard.theme.toLowerCase() !== "system" + ? dashboard.theme + : null const handleStartEditHeader = () => { setDraftName(name) @@ -1257,13 +1396,15 @@ function BuilderHeader({ Formato {dashboard.aspectRatio ?? "16:9"} - - - Tema {dashboard.theme ?? "system"} - + {themeLabel ? ( + + + Tema {themeLabel} + + ) : null} {isFullscreen ? : } {isFullscreen ? "Sair da tela cheia" : "Tela cheia"} @@ -1370,6 +1511,17 @@ function BuilderHeader({ + {canEdit ? ( + + ) : null}
@@ -1522,6 +1674,54 @@ function DashboardFilterBar({ ) } +function TvCanvas({ items, isFullscreen }: { items: CanvasRenderableItem[]; isFullscreen: boolean }) { + if (items.length === 0) { + return ( +
+
+ Nenhum widget disponível para esta seção. +
+
+ ) + } + + const renderItems = items.slice(0, 4) + + const isSingle = renderItems.length === 1 + const gridClass = isSingle + ? "grid-cols-1 place-items-center" + : renderItems.length === 2 + ? "grid-cols-1 md:grid-cols-2 md:grid-rows-1" + : "grid-cols-1 md:grid-cols-2 md:grid-rows-2" + + return ( +
+
+
+
+ {renderItems.map((item) => ( +
+ {item.element} +
+ ))} +
+
+
+
+ ) +} + function BuilderWidgetCard({ widget, filters, @@ -1981,16 +2181,24 @@ function TvSectionIndicator({ sections, activeIndex, onChange, + fullscreen = false, }: { sections: DashboardSection[] activeIndex: number onChange: (index: number) => void + fullscreen?: boolean }) { if (sections.length === 0) return null return ( -
+
- + Seção {activeIndex + 1} de {sections.length}: {sections[activeIndex]?.title ?? "Sem título"} @@ -2002,7 +2210,13 @@ function TvSectionIndicator({ onClick={() => onChange(index)} className={cn( "h-2.5 w-2.5 rounded-full transition", - index === activeIndex ? "bg-primary" : "bg-primary/30 hover:bg-primary/60", + fullscreen + ? index === activeIndex + ? "bg-white" + : "bg-white/40 hover:bg-white/70" + : index === activeIndex + ? "bg-sidebar-accent-foreground" + : "bg-sidebar-accent-foreground/40 hover:bg-sidebar-accent-foreground/70", )} /> ))} diff --git a/src/components/dashboards/dashboard-list.tsx b/src/components/dashboards/dashboard-list.tsx index 520335a..441098b 100644 --- a/src/components/dashboards/dashboard-list.tsx +++ b/src/components/dashboards/dashboard-list.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation" import { useMutation, useQuery } from "convex/react" import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" -import { Plus, Sparkles } from "lucide-react" +import { Plus, Sparkles, Trash2 } from "lucide-react" import type { Id } from "@/convex/_generated/dataModel" import { api } from "@/convex/_generated/api" @@ -21,7 +21,7 @@ import { CardHeader, CardTitle, } from "@/components/ui/card" -import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" @@ -131,7 +131,10 @@ export function DashboardListView() { const { session, convexUserId, isStaff } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const createDashboard = useMutation(api.dashboards.create) + const archiveDashboard = useMutation(api.dashboards.archive) const [isCreating, setIsCreating] = useState(false) + const [dashboardToDelete, setDashboardToDelete] = useState(null) + const [isDeleting, setIsDeleting] = useState(false) const dashboards = useQuery( api.dashboards.list, @@ -163,6 +166,26 @@ export function DashboardListView() { } } + async function handleConfirmDelete() { + if (!dashboardToDelete || !convexUserId) return + setIsDeleting(true) + try { + await archiveDashboard({ + tenantId, + actorId: convexUserId as Id<"users">, + dashboardId: dashboardToDelete.id, + archived: true, + }) + toast.success("Dashboard removido.") + setDashboardToDelete(null) + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o dashboard.") + } finally { + setIsDeleting(false) + } + } + if (!isStaff) { return ( @@ -265,22 +288,64 @@ export function DashboardListView() { Formato {dashboard.aspectRatio} - - - Tema {dashboard.theme} - + {dashboard.theme && dashboard.theme.toLowerCase() !== "system" ? ( + + + Tema {dashboard.theme} + + ) : null}
- - + ) })}
)} + { + if (!open && !isDeleting) { + setDashboardToDelete(null) + } + }} + > + + + Excluir dashboard + + Essa ação remove o dashboard para toda a equipe. Confirme para continuar. + + + + + + + +
) } diff --git a/src/components/dashboards/widget-renderer.tsx b/src/components/dashboards/widget-renderer.tsx index 829a796..f3cf63e 100644 --- a/src/components/dashboards/widget-renderer.tsx +++ b/src/components/dashboards/widget-renderer.tsx @@ -299,7 +299,7 @@ export function WidgetRenderer({ widget, filters, mode = "edit", onReadyChange } const isLoading = metric.isLoading || (widgetType === "kpi" && Boolean(config.options?.trend) && trendMetric.isLoading) const isError = metric.isError - const resolvedTitle = mode === "tv" ? title.toUpperCase() : title + const resolvedTitle = title if (isError) { return ( @@ -432,7 +432,9 @@ function renderKpi({ @@ -443,7 +445,7 @@ function renderKpi({
{numberFormatter.format(value)}
-
+
0 ? "destructive" : "secondary"} className={cn( diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3600a88..beb8438 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -23,8 +23,8 @@ export const auth = betterAuth({ provider: "sqlite", }), user: { - // Prisma model name (case-sensitive) - modelName: "AuthUser", + // Use the exact Prisma client property names (lower camel case) + modelName: "authUser", additionalFields: { role: { type: "string", @@ -48,17 +48,17 @@ export const auth = betterAuth({ }, }, session: { - modelName: "AuthSession", + modelName: "authSession", cookieCache: { enabled: true, maxAge: 60 * 5, }, }, account: { - modelName: "AuthAccount", + modelName: "authAccount", }, verification: { - modelName: "AuthVerification", + modelName: "authVerification", }, emailAndPassword: { enabled: true, diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 78bd401..b407ce7 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,5 +1,4 @@ import path from "node:path" -import { fileURLToPath, pathToFileURL } from "node:url" import { PrismaClient } from "@prisma/client" @@ -9,10 +8,7 @@ declare global { // Resolve a robust DATABASE_URL for all runtimes (prod/dev) const PROJECT_ROOT = process.cwd() -const PROJECT_ROOT_URL = (() => { - const baseHref = pathToFileURL(PROJECT_ROOT).href - return new URL(baseHref.endsWith("/") ? baseHref : `${baseHref}/`) -})() +const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma") function resolveFileUrl(url: string) { if (!url.startsWith("file:")) { @@ -20,31 +16,44 @@ function resolveFileUrl(url: string) { } const filePath = url.slice("file:".length) - if (filePath.startsWith("./") || filePath.startsWith("../")) { - const normalized = path.normalize(filePath) - const targetUrl = new URL(normalized, PROJECT_ROOT_URL) - const absolutePath = fileURLToPath(targetUrl) - if (!absolutePath.startsWith(PROJECT_ROOT)) { - throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`) - } - return `file:${absolutePath}` + + if (filePath.startsWith("//")) { + return url } - if (!filePath.startsWith("/")) { - return resolveFileUrl(`file:./${filePath}`) + + if (path.isAbsolute(filePath)) { + return `file:${path.normalize(filePath)}` } - return url + + const normalized = path.normalize(filePath) + const prismaPrefix = `prisma${path.sep}` + const relativeToPrisma = normalized.startsWith(prismaPrefix) + ? normalized.slice(prismaPrefix.length) + : normalized + + const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma) + + if (!absolutePath.startsWith(PROJECT_ROOT)) { + throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`) + } + + return `file:${absolutePath}` } -const resolvedDatabaseUrl = (() => { - const envUrl = process.env.DATABASE_URL?.trim() - if (envUrl && envUrl.length > 0) return resolveFileUrl(envUrl) - // Fallbacks by environment to ensure correctness in containers +function normalizeDatasourceUrl(envUrl?: string | null) { + const trimmed = envUrl?.trim() + if (trimmed) { + return resolveFileUrl(trimmed) + } + if (process.env.NODE_ENV === "production") { return "file:/app/data/db.sqlite" } - // In development, prefer a dedicated dev DB file - return resolveFileUrl("file:./prisma/db.dev.sqlite") -})() + + return resolveFileUrl("file:./db.dev.sqlite") +} + +const resolvedDatabaseUrl = normalizeDatasourceUrl(process.env.DATABASE_URL) export const prisma = global.prisma ?? new PrismaClient({ datasources: { db: { url: resolvedDatabaseUrl } } })