fix: ajustes dashboards tv e titulos
This commit is contained in:
parent
80abd92e78
commit
1b32638eb5
9 changed files with 609 additions and 232 deletions
30
README.md
30
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:
|
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)
|
- `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:
|
3. Aplique as migrações e gere o client Prisma:
|
||||||
```bash
|
```bash
|
||||||
bunx prisma migrate deploy
|
bunx prisma migrate deploy
|
||||||
|
|
@ -26,21 +26,27 @@ Aplicação **Next.js 16 (App Router)** com **React 19**, **Convex** e **Better
|
||||||
```bash
|
```bash
|
||||||
bun run auth:seed
|
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
|
```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
|
```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.
|
4. Suba o servidor normalmente com `bun run dev`. Esses três comandos bastam para reconstruir o ambiente sempre que trocar de computador.
|
||||||
7. Em outro terminal, suba o frontend Next.js (dev com Turbopack):
|
|
||||||
```bash
|
### Subir serviços locais
|
||||||
bun run dev:bun
|
|
||||||
```
|
- (Opcional) Para re-sincronizar manualmente as filas padrão, execute `bun run queues:ensure`.
|
||||||
> Fallback: `bun run dev:webpack` caso o Turbopack acione alguma incompatibilidade.
|
- 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).
|
||||||
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.
|
- 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.
|
> 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.
|
||||||
|
|
||||||
|
|
|
||||||
176
src/app/api/dashboards/[id]/export/[format]/route.ts
Normal file
176
src/app/api/dashboards/[id]/export/[format]/route.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ReturnType<typeof chromium.launch>> | 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -71,6 +71,7 @@ import {
|
||||||
SearchableCombobox,
|
SearchableCombobox,
|
||||||
type SearchableComboboxOption,
|
type SearchableComboboxOption,
|
||||||
} from "@/components/ui/searchable-combobox"
|
} from "@/components/ui/searchable-combobox"
|
||||||
|
import { useSidebar } from "@/components/ui/sidebar"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
|
|
@ -530,6 +531,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
const tvQuery = searchParams?.get("tv")
|
const tvQuery = searchParams?.get("tv")
|
||||||
const enforceTv = tvQuery === "1" || mode === "tv"
|
const enforceTv = tvQuery === "1" || mode === "tv"
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
|
const { open, setOpen, openMobile, setOpenMobile, isMobile } = useSidebar()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const viewerId = convexUserId as Id<"users"> | null
|
const viewerId = convexUserId as Id<"users"> | null
|
||||||
const canEdit = editable && Boolean(viewerId) && isStaff
|
const canEdit = editable && Boolean(viewerId) && isStaff
|
||||||
|
|
@ -561,6 +563,11 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
const [dataTarget, setDataTarget] = useState<DashboardWidgetRecord | null>(null)
|
const [dataTarget, setDataTarget] = useState<DashboardWidgetRecord | null>(null)
|
||||||
const [isAddingWidget, setIsAddingWidget] = useState(false)
|
const [isAddingWidget, setIsAddingWidget] = useState(false)
|
||||||
const [isExporting, setIsExporting] = 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<HTMLDivElement | null>(null)
|
||||||
|
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
|
||||||
|
|
||||||
const updateLayoutMutation = useMutation(api.dashboards.updateLayout)
|
const updateLayoutMutation = useMutation(api.dashboards.updateLayout)
|
||||||
const updateFiltersMutation = useMutation(api.dashboards.updateFilters)
|
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 duplicateWidgetMutation = useMutation(api.dashboards.duplicateWidget)
|
||||||
const removeWidgetMutation = useMutation(api.dashboards.removeWidget)
|
const removeWidgetMutation = useMutation(api.dashboards.removeWidget)
|
||||||
const updateMetadataMutation = useMutation(api.dashboards.updateMetadata)
|
const updateMetadataMutation = useMutation(api.dashboards.updateMetadata)
|
||||||
|
const archiveDashboardMutation = useMutation(api.dashboards.archive)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return
|
if (!detail) return
|
||||||
|
|
@ -588,10 +596,78 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
filtersHydratingRef.current = false
|
filtersHydratingRef.current = false
|
||||||
}, [detail, filters])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
layoutRef.current = layoutState
|
layoutRef.current = layoutState
|
||||||
}, [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 packedLayout = useMemo(() => packLayout(layoutState, GRID_COLUMNS), [layoutState])
|
||||||
|
|
||||||
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
|
const metricOptions = useMemo(() => getMetricOptionsForRole(userRole), [userRole])
|
||||||
|
|
@ -602,8 +678,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
return map
|
return map
|
||||||
}, [widgets])
|
}, [widgets])
|
||||||
|
|
||||||
const sections = useMemo(() => dashboard?.sections ?? [], [dashboard?.sections])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enforceTv || sections.length <= 1) return
|
if (!enforceTv || sections.length <= 1) return
|
||||||
const intervalSeconds = dashboard?.tvIntervalSeconds && dashboard.tvIntervalSeconds > 0 ? dashboard.tvIntervalSeconds : 30
|
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") => {
|
const handleExport = async (format: "pdf" | "png") => {
|
||||||
if (!dashboard) return
|
if (!dashboard) return
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/export/pdf", {
|
const response = await fetch(`/api/dashboards/${dashboard.id}/export/${format}`)
|
||||||
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']",
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let msg = "Export request failed"
|
let message = `Export request failed (${response.status})`
|
||||||
|
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||||
try {
|
try {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data?.hint) msg = `${msg}: ${data.hint}`
|
if (data?.hint) message = data.hint
|
||||||
else if (data?.error) msg = `${msg}: ${data.error}`
|
else if (data?.error) message = data.error
|
||||||
} catch {}
|
} catch {
|
||||||
throw new Error(msg)
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement("a")
|
const link = document.createElement("a")
|
||||||
link.href = url
|
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()
|
link.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
toast.success("Exportação gerada com sucesso!")
|
toast.success("Exportação gerada com sucesso!")
|
||||||
|
|
@ -993,7 +1087,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
const visibleCount = canvasItems.length
|
const visibleCount = canvasItems.length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div
|
||||||
|
ref={fullscreenContainerRef}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 flex-col gap-6",
|
||||||
|
isFullscreen &&
|
||||||
|
"min-h-screen bg-gradient-to-br from-background via-background to-primary/5 pb-10 pt-16",
|
||||||
|
isFullscreen && (enforceTv ? "px-0" : "px-4 md:px-8 lg:px-12"),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<div className="pointer-events-none fixed right-6 top-6 z-50 flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="pointer-events-auto gap-2 rounded-full border border-slate-200 bg-white/90 px-4 py-2 text-sm font-semibold text-slate-900 shadow-lg transition hover:bg-white"
|
||||||
|
onClick={handleToggleFullscreen}
|
||||||
|
>
|
||||||
|
<Minimize2 className="size-4" />
|
||||||
|
Sair da tela cheia
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isFullscreen ? (
|
||||||
<BuilderHeader
|
<BuilderHeader
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
|
@ -1007,15 +1124,20 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
totalSections={sections.length}
|
totalSections={sections.length}
|
||||||
onToggleTvMode={handleToggleTvMode}
|
onToggleTvMode={handleToggleTvMode}
|
||||||
totalWidgets={widgets.length}
|
totalWidgets={widgets.length}
|
||||||
|
onDeleteRequest={() => setIsDeleteDialogOpen(true)}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onToggleFullscreen={handleToggleFullscreen}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<DashboardFilterBar filters={filters} onChange={handleFiltersChange} />
|
{!isFullscreen ? <DashboardFilterBar filters={filters} onChange={handleFiltersChange} /> : null}
|
||||||
|
|
||||||
{enforceTv && sections.length > 0 ? (
|
{enforceTv && sections.length > 0 ? (
|
||||||
<TvSectionIndicator
|
<TvSectionIndicator
|
||||||
sections={sections}
|
sections={sections}
|
||||||
activeIndex={activeSectionIndex}
|
activeIndex={activeSectionIndex}
|
||||||
onChange={setActiveSectionIndex}
|
onChange={setActiveSectionIndex}
|
||||||
|
fullscreen={isFullscreen}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -1043,6 +1165,9 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{enforceTv ? (
|
||||||
|
<TvCanvas items={canvasItems} isFullscreen={isFullscreen} />
|
||||||
|
) : (
|
||||||
<ReportCanvas
|
<ReportCanvas
|
||||||
items={canvasItems}
|
items={canvasItems}
|
||||||
editable={canEdit && !enforceTv && mode !== "print"}
|
editable={canEdit && !enforceTv && mode !== "print"}
|
||||||
|
|
@ -1053,6 +1178,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
onResize={handleLayoutResize}
|
onResize={handleLayoutResize}
|
||||||
onReorder={handleLayoutReorder}
|
onReorder={handleLayoutReorder}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{canEdit && !enforceTv ? (
|
{canEdit && !enforceTv ? (
|
||||||
<button
|
<button
|
||||||
|
|
@ -1084,6 +1210,37 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
widget={dataTarget}
|
widget={dataTarget}
|
||||||
filters={filters}
|
filters={filters}
|
||||||
/>
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isDeletingDashboard) {
|
||||||
|
setIsDeleteDialogOpen(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Excluir dashboard</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Essa ação remove o dashboard definitivamente para toda a equipe. Deseja continuar?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isDeletingDashboard) setIsDeleteDialogOpen(false)
|
||||||
|
}}
|
||||||
|
disabled={isDeletingDashboard}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDeleteDashboard} disabled={isDeletingDashboard}>
|
||||||
|
{isDeletingDashboard ? "Removendo..." : "Excluir dashboard"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1101,6 +1258,9 @@ function BuilderHeader({
|
||||||
totalSections,
|
totalSections,
|
||||||
onToggleTvMode,
|
onToggleTvMode,
|
||||||
totalWidgets,
|
totalWidgets,
|
||||||
|
onDeleteRequest,
|
||||||
|
isFullscreen = false,
|
||||||
|
onToggleFullscreen,
|
||||||
}: {
|
}: {
|
||||||
dashboard: DashboardRecord
|
dashboard: DashboardRecord
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
|
|
@ -1114,6 +1274,9 @@ function BuilderHeader({
|
||||||
totalSections: number
|
totalSections: number
|
||||||
onToggleTvMode: () => void
|
onToggleTvMode: () => void
|
||||||
totalWidgets: number
|
totalWidgets: number
|
||||||
|
onDeleteRequest?: () => void
|
||||||
|
isFullscreen?: boolean
|
||||||
|
onToggleFullscreen: () => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(dashboard.name)
|
const [name, setName] = useState(dashboard.name)
|
||||||
const [description, setDescription] = useState(dashboard.description ?? "")
|
const [description, setDescription] = useState(dashboard.description ?? "")
|
||||||
|
|
@ -1121,7 +1284,6 @@ function BuilderHeader({
|
||||||
const [draftName, setDraftName] = useState(dashboard.name)
|
const [draftName, setDraftName] = useState(dashboard.name)
|
||||||
const [draftDescription, setDraftDescription] = useState(dashboard.description ?? "")
|
const [draftDescription, setDraftDescription] = useState(dashboard.description ?? "")
|
||||||
const [isSavingHeader, setIsSavingHeader] = useState(false)
|
const [isSavingHeader, setIsSavingHeader] = useState(false)
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(dashboard.name)
|
setName(dashboard.name)
|
||||||
|
|
@ -1132,34 +1294,11 @@ function BuilderHeader({
|
||||||
}
|
}
|
||||||
}, [dashboard.name, dashboard.description, isEditingHeader])
|
}, [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 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 = () => {
|
const handleStartEditHeader = () => {
|
||||||
setDraftName(name)
|
setDraftName(name)
|
||||||
|
|
@ -1257,13 +1396,15 @@ function BuilderHeader({
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Formato {dashboard.aspectRatio ?? "16:9"}
|
Formato {dashboard.aspectRatio ?? "16:9"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{themeLabel ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
|
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
|
||||||
>
|
>
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Tema {dashboard.theme ?? "system"}
|
Tema {themeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : null}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
|
className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-4 py-1.5 text-xs font-semibold text-neutral-700 shadow-sm"
|
||||||
|
|
@ -1307,7 +1448,7 @@ function BuilderHeader({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2"
|
className="gap-2"
|
||||||
onClick={toggleFullscreen}
|
onClick={onToggleFullscreen}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
{isFullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||||
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
|
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
|
||||||
|
|
@ -1370,6 +1511,17 @@ function BuilderHeader({
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{canEdit ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:text-rose-700 hover:text-rose-700"
|
||||||
|
onClick={onDeleteRequest}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Excluir
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -1522,6 +1674,54 @@ function DashboardFilterBar({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TvCanvas({ items, isFullscreen }: { items: CanvasRenderableItem[]; isFullscreen: boolean }) {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-4xl">
|
||||||
|
<div className="aspect-video w-full rounded-3xl border border-dashed border-border/60 bg-muted/30 text-sm text-muted-foreground shadow-inner flex items-center justify-center">
|
||||||
|
Nenhum widget disponível para esta seção.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-auto w-full px-4",
|
||||||
|
isFullscreen ? "flex-1 py-8 md:px-8 lg:px-12" : "py-6 md:px-6 lg:px-8",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl lg:max-w-6xl xl:max-w-7xl">
|
||||||
|
<div className="aspect-video w-full overflow-hidden rounded-3xl border border-border/60 bg-white shadow-2xl ring-1 ring-black/5">
|
||||||
|
<div className={cn("grid h-full w-full gap-4 p-4 md:gap-6 md:p-6", gridClass)}>
|
||||||
|
{renderItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-2xl border border-border/40 bg-gradient-to-br from-white via-white to-slate-100 shadow-lg",
|
||||||
|
isSingle ? "max-w-4xl justify-self-center" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.element}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BuilderWidgetCard({
|
function BuilderWidgetCard({
|
||||||
widget,
|
widget,
|
||||||
filters,
|
filters,
|
||||||
|
|
@ -1981,16 +2181,24 @@ function TvSectionIndicator({
|
||||||
sections,
|
sections,
|
||||||
activeIndex,
|
activeIndex,
|
||||||
onChange,
|
onChange,
|
||||||
|
fullscreen = false,
|
||||||
}: {
|
}: {
|
||||||
sections: DashboardSection[]
|
sections: DashboardSection[]
|
||||||
activeIndex: number
|
activeIndex: number
|
||||||
onChange: (index: number) => void
|
onChange: (index: number) => void
|
||||||
|
fullscreen?: boolean
|
||||||
}) {
|
}) {
|
||||||
if (sections.length === 0) return null
|
if (sections.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-primary/30 bg-primary/5 px-4 py-2 text-sm text-primary">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent px-4 py-2 text-sm font-semibold text-sidebar-accent-foreground shadow-sm",
|
||||||
|
fullscreen &&
|
||||||
|
"fixed left-1/2 top-6 z-40 w-[min(92vw,960px)] -translate-x-1/2 border-white/20 bg-slate-950/80 text-white shadow-xl backdrop-blur",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<MonitorPlay className="size-4" />
|
<MonitorPlay className={cn("size-4", fullscreen ? "text-white" : "text-sidebar-accent-foreground/80")} />
|
||||||
<span>
|
<span>
|
||||||
Seção {activeIndex + 1} de {sections.length}: {sections[activeIndex]?.title ?? "Sem título"}
|
Seção {activeIndex + 1} de {sections.length}: {sections[activeIndex]?.title ?? "Sem título"}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2002,7 +2210,13 @@ function TvSectionIndicator({
|
||||||
onClick={() => onChange(index)}
|
onClick={() => onChange(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-2.5 w-2.5 rounded-full transition",
|
"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",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { formatDistanceToNow } from "date-fns"
|
import { formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
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 type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card"
|
} 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 { Input } from "@/components/ui/input"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
@ -131,7 +131,10 @@ export function DashboardListView() {
|
||||||
const { session, convexUserId, isStaff } = useAuth()
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
const createDashboard = useMutation(api.dashboards.create)
|
const createDashboard = useMutation(api.dashboards.create)
|
||||||
|
const archiveDashboard = useMutation(api.dashboards.archive)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [dashboardToDelete, setDashboardToDelete] = useState<DashboardSummary | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
const dashboards = useQuery(
|
const dashboards = useQuery(
|
||||||
api.dashboards.list,
|
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) {
|
if (!isStaff) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-dashed border-primary/40 bg-primary/5 text-primary">
|
<Card className="border-dashed border-primary/40 bg-primary/5 text-primary">
|
||||||
|
|
@ -265,22 +288,64 @@ export function DashboardListView() {
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Formato {dashboard.aspectRatio}
|
Formato {dashboard.aspectRatio}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{dashboard.theme && dashboard.theme.toLowerCase() !== "system" ? (
|
||||||
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
|
<Badge variant="outline" className="inline-flex items-center gap-2 rounded-full border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700">
|
||||||
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
<span className="h-2 w-2 rounded-full bg-neutral-400" />
|
||||||
Tema {dashboard.theme}
|
Tema {dashboard.theme}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter className="flex gap-2">
|
||||||
<Button asChild className="w-full">
|
<Button asChild className="flex-1">
|
||||||
<Link href={`/dashboards/${dashboard.id}`}>Abrir dashboard</Link>
|
<Link href={`/dashboards/${dashboard.id}`}>Abrir dashboard</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 size-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:text-rose-700"
|
||||||
|
onClick={() => setDashboardToDelete(dashboard)}
|
||||||
|
aria-label={`Excluir ${dashboard.name}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(dashboardToDelete)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open && !isDeleting) {
|
||||||
|
setDashboardToDelete(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Excluir dashboard</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Essa ação remove o dashboard para toda a equipe. Confirme para continuar.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isDeleting) setDashboardToDelete(null)
|
||||||
|
}}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||||||
|
{isDeleting ? "Removendo..." : "Excluir dashboard"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 isLoading = metric.isLoading || (widgetType === "kpi" && Boolean(config.options?.trend) && trendMetric.isLoading)
|
||||||
const isError = metric.isError
|
const isError = metric.isError
|
||||||
const resolvedTitle = mode === "tv" ? title.toUpperCase() : title
|
const resolvedTitle = title
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -432,7 +432,9 @@ function renderKpi({
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col rounded-2xl border bg-gradient-to-br shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-xl",
|
"flex h-full flex-col rounded-2xl border bg-gradient-to-br shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-xl",
|
||||||
isTv ? "border-primary/50 from-primary/10 via-primary/5 to-primary/20" : "border-slate-200 from-white via-white to-slate-100"
|
isTv
|
||||||
|
? "border-slate-400/60 from-slate-900 via-slate-800 to-slate-700 text-white"
|
||||||
|
: "border-slate-200 from-white via-white to-slate-100 text-neutral-900"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="flex-none pb-1">
|
<CardHeader className="flex-none pb-1">
|
||||||
|
|
@ -443,7 +445,7 @@ function renderKpi({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 space-y-3">
|
<CardContent className="flex-1 space-y-3">
|
||||||
<div className={cn("font-semibold text-4xl", isTv ? "text-6xl" : "text-4xl")}>{numberFormatter.format(value)}</div>
|
<div className={cn("font-semibold text-4xl", isTv ? "text-6xl" : "text-4xl")}>{numberFormatter.format(value)}</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
<div className={cn("flex flex-wrap items-center gap-2 text-sm text-muted-foreground", isTv ? "text-slate-200" : "")}>
|
||||||
<Badge
|
<Badge
|
||||||
variant={atRisk > 0 ? "destructive" : "secondary"}
|
variant={atRisk > 0 ? "destructive" : "secondary"}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,8 @@ export const auth = betterAuth({
|
||||||
provider: "sqlite",
|
provider: "sqlite",
|
||||||
}),
|
}),
|
||||||
user: {
|
user: {
|
||||||
// Prisma model name (case-sensitive)
|
// Use the exact Prisma client property names (lower camel case)
|
||||||
modelName: "AuthUser",
|
modelName: "authUser",
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
role: {
|
role: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|
@ -48,17 +48,17 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
modelName: "AuthSession",
|
modelName: "authSession",
|
||||||
cookieCache: {
|
cookieCache: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: 60 * 5,
|
maxAge: 60 * 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
modelName: "AuthAccount",
|
modelName: "authAccount",
|
||||||
},
|
},
|
||||||
verification: {
|
verification: {
|
||||||
modelName: "AuthVerification",
|
modelName: "authVerification",
|
||||||
},
|
},
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client"
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
|
||||||
|
|
@ -9,10 +8,7 @@ declare global {
|
||||||
|
|
||||||
// Resolve a robust DATABASE_URL for all runtimes (prod/dev)
|
// Resolve a robust DATABASE_URL for all runtimes (prod/dev)
|
||||||
const PROJECT_ROOT = process.cwd()
|
const PROJECT_ROOT = process.cwd()
|
||||||
const PROJECT_ROOT_URL = (() => {
|
const PRISMA_DIR = path.join(PROJECT_ROOT, "prisma")
|
||||||
const baseHref = pathToFileURL(PROJECT_ROOT).href
|
|
||||||
return new URL(baseHref.endsWith("/") ? baseHref : `${baseHref}/`)
|
|
||||||
})()
|
|
||||||
|
|
||||||
function resolveFileUrl(url: string) {
|
function resolveFileUrl(url: string) {
|
||||||
if (!url.startsWith("file:")) {
|
if (!url.startsWith("file:")) {
|
||||||
|
|
@ -20,31 +16,44 @@ function resolveFileUrl(url: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = url.slice("file:".length)
|
const filePath = url.slice("file:".length)
|
||||||
if (filePath.startsWith("./") || filePath.startsWith("../")) {
|
|
||||||
|
if (filePath.startsWith("//")) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isAbsolute(filePath)) {
|
||||||
|
return `file:${path.normalize(filePath)}`
|
||||||
|
}
|
||||||
|
|
||||||
const normalized = path.normalize(filePath)
|
const normalized = path.normalize(filePath)
|
||||||
const targetUrl = new URL(normalized, PROJECT_ROOT_URL)
|
const prismaPrefix = `prisma${path.sep}`
|
||||||
const absolutePath = fileURLToPath(targetUrl)
|
const relativeToPrisma = normalized.startsWith(prismaPrefix)
|
||||||
|
? normalized.slice(prismaPrefix.length)
|
||||||
|
: normalized
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(PRISMA_DIR, relativeToPrisma)
|
||||||
|
|
||||||
if (!absolutePath.startsWith(PROJECT_ROOT)) {
|
if (!absolutePath.startsWith(PROJECT_ROOT)) {
|
||||||
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
|
throw new Error(`DATABASE_URL path escapes project directory: ${filePath}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return `file:${absolutePath}`
|
return `file:${absolutePath}`
|
||||||
}
|
|
||||||
if (!filePath.startsWith("/")) {
|
|
||||||
return resolveFileUrl(`file:./${filePath}`)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedDatabaseUrl = (() => {
|
function normalizeDatasourceUrl(envUrl?: string | null) {
|
||||||
const envUrl = process.env.DATABASE_URL?.trim()
|
const trimmed = envUrl?.trim()
|
||||||
if (envUrl && envUrl.length > 0) return resolveFileUrl(envUrl)
|
if (trimmed) {
|
||||||
// Fallbacks by environment to ensure correctness in containers
|
return resolveFileUrl(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
return "file:/app/data/db.sqlite"
|
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 =
|
export const prisma =
|
||||||
global.prisma ?? new PrismaClient({ datasources: { db: { url: resolvedDatabaseUrl } } })
|
global.prisma ?? new PrismaClient({ datasources: { db: { url: resolvedDatabaseUrl } } })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue