fix(dashboards): prevent render loops with stable ready handlers and idempotent updates; improve filter hydration guards

fix(export): return 501 with hint when Playwright browsers missing; nicer error toast in UI

fix(site-header): export primary/secondary buttons as named for SC safety; keep static props for compat

fix(portal): add DialogDescription for a11y; tidy preview dialog

fix(csats): avoid reinit state loops with timestamp guard

chore(prisma): default dev DB to prisma/db.dev.sqlite and log path

chore(auth): add dev bypass flags wiring (server/client) for local testing

dev: seed script for Convex demo data
This commit is contained in:
Esdras Renan 2025-11-06 00:01:45 -03:00
parent ff0254df18
commit b62e14d8eb
13 changed files with 210 additions and 103 deletions

View file

@ -441,6 +441,14 @@ function filtersEqual(a: DashboardFilters, b: DashboardFilters) {
)
}
function deepEqual<T>(a: T, b: T) {
try {
return JSON.stringify(a) === JSON.stringify(b)
} catch {
return false
}
}
const widgetConfigSchema = z.object({
title: z.string().min(1, "Informe um título"),
type: z.string(),
@ -500,12 +508,14 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
useEffect(() => {
if (!detail) return
setDashboard(detail.dashboard)
setWidgets(detail.widgets)
setShares(detail.shares)
setDashboard((prev) => (prev && deepEqual(prev, detail.dashboard) ? prev : detail.dashboard))
setWidgets((prev) => (deepEqual(prev, detail.widgets) ? prev : detail.widgets))
setShares((prev) => (deepEqual(prev, detail.shares) ? prev : detail.shares))
const nextFilters = normalizeFilters(detail.dashboard.filters)
filtersHydratingRef.current = true
setFilters(nextFilters)
if (!filtersEqual(filters, nextFilters)) {
filtersHydratingRef.current = true
setFilters(nextFilters)
}
const syncedLayout = syncLayoutWithWidgets(layoutRef.current, detail.widgets, detail.dashboard.layout)
if (!layoutItemsEqual(layoutRef.current, syncedLayout)) {
layoutRef.current = syncedLayout
@ -543,6 +553,30 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
return new Set(currentSection?.widgetKeys ?? [])
}, [enforceTv, sections, activeSectionIndex])
// Ready handlers (stable per widget) defined before usage in canvasItems
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
setReadyWidgets((prev) => {
const currentlyReady = prev.has(key)
if (currentlyReady === ready) return prev
const next = new Set(prev)
if (ready) next.add(key)
else next.delete(key)
return next
})
}, [])
const readyHandlersRef = useRef(new Map<string, (ready: boolean) => void>())
const getReadyHandler = useCallback(
(key: string) => {
const cached = readyHandlersRef.current.get(key)
if (cached) return cached
const handler = (ready: boolean) => handleWidgetReady(key, ready)
readyHandlersRef.current.set(key, handler)
return handler
},
[handleWidgetReady],
)
const canvasItems = packedLayout
.map((item) => {
const widget = widgetMap.get(item.i)
@ -565,7 +599,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
onDuplicate={() => handleDuplicateWidget(widget)}
onRemove={() => handleRemoveWidget(widget)}
onViewData={() => setDataTarget(widget)}
onReadyChange={(ready) => handleWidgetReady(widget.widgetKey, ready)}
onReadyChange={getReadyHandler(widget.widgetKey)}
/>
),
...(item.minW !== undefined ? { minW: item.minW } : {}),
@ -576,18 +610,6 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
const allWidgetsReady = canvasItems.length > 0 && canvasItems.every((item) => readyWidgets.has(item.key))
const handleWidgetReady = useCallback((key: string, ready: boolean) => {
setReadyWidgets((prev) => {
const next = new Set(prev)
if (ready) {
next.add(key)
} else {
next.delete(key)
}
return next
})
}, [])
useEffect(() => {
const keys = new Set(canvasItems.map((item) => item.key))
setReadyWidgets((prev) => {
@ -799,7 +821,13 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
}),
})
if (!response.ok) {
throw new Error("Export request failed")
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)
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
@ -811,7 +839,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
toast.success("Exportação gerada com sucesso!")
} catch (error) {
console.error(error)
toast.error("Não foi possível exportar o dashboard.")
const message = error instanceof Error ? error.message : String(error)
toast.error(message.includes("Playwright browsers not installed") || message.includes("npx playwright install")
? "Exportação indisponível em DEV: rode `npx playwright install` para habilitar."
: "Não foi possível exportar o dashboard.")
} finally {
setIsExporting(false)
}

View file

@ -15,7 +15,7 @@ import { useAuth } from "@/lib/auth-client"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Dropzone } from "@/components/ui/dropzone"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Skeleton } from "@/components/ui/skeleton"
@ -513,6 +513,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
<X className="size-4" />
</DialogClose>
</DialogHeader>
<DialogDescription className="sr-only">
{isPreviewImage
? "Pré-visualização da imagem anexada."
: "Pré-visualização do arquivo selecionado. Utilize o botão para abrir em uma nova aba."}
</DialogDescription>
{previewAttachment ? (
isPreviewImage ? (
<div className="rounded-b-2xl bg-neutral-900/5">
@ -696,5 +701,3 @@ function PortalCommentAttachmentCard({

View file

@ -42,8 +42,8 @@ export function SiteHeader({
</header>
)
}
SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
export function SiteHeaderPrimaryButton({
children,
className,
...props
@ -55,7 +55,7 @@ SiteHeader.PrimaryButton = function SiteHeaderPrimaryButton({
)
}
SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
export function SiteHeaderSecondaryButton({
children,
className,
...props
@ -66,3 +66,7 @@ SiteHeader.SecondaryButton = function SiteHeaderSecondaryButton({
</Button>
)
}
// Backward compatibility: attach as static members (client-only usage)
;(SiteHeader as any).PrimaryButton = SiteHeaderPrimaryButton
;(SiteHeader as any).SecondaryButton = SiteHeaderSecondaryButton

View file

@ -90,12 +90,20 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const [hoverScore, setHoverScore] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
const ratedAtTimestamp = ticket.csatRatedAt ? ticket.csatRatedAt.getTime() : null
useEffect(() => {
setScore(initialScore)
setComment(initialComment)
setRatedAt(ticket.csatRatedAt ?? null)
setHasSubmitted(initialScore > 0)
}, [initialScore, initialComment, ticket.csatRatedAt])
setScore((prev) => (prev === initialScore ? prev : initialScore))
setComment((prev) => (prev === initialComment ? prev : initialComment))
const nextRatedAt = ratedAtTimestamp == null ? null : new Date(ratedAtTimestamp)
setRatedAt((prev) => {
if (prev === nextRatedAt) return prev
if (prev && nextRatedAt && prev.getTime() === nextRatedAt.getTime()) return prev
return nextRatedAt
})
const nextHasSubmitted = initialScore > 0
setHasSubmitted((prev) => (prev === nextHasSubmitted ? prev : nextHasSubmitted))
}, [initialScore, initialComment, ratedAtTimestamp])
const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER"