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:
parent
ff0254df18
commit
b62e14d8eb
13 changed files with 210 additions and 103 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue