From 4655c7570a8788335aa15b0c97aa219633300424 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Fri, 7 Nov 2025 00:56:59 -0300 Subject: [PATCH] Atualiza dashboards e painel de tickets --- convex/dashboards.ts | 4 +- src/components/app-sidebar.tsx | 2 +- .../dashboards/dashboard-builder.tsx | 189 ++++--- src/components/dashboards/dashboard-list.tsx | 14 +- src/components/dashboards/widget-renderer.tsx | 2 +- .../tickets/ticket-custom-fields.tsx | 510 +++++++++--------- .../tickets/ticket-details-panel.tsx | 43 +- .../tickets/ticket-queue-summary.tsx | 137 ++--- .../tickets/ticket-summary-header.tsx | 2 + 9 files changed, 483 insertions(+), 420 deletions(-) diff --git a/convex/dashboards.ts b/convex/dashboards.ts index 344a150..bfde24c 100644 --- a/convex/dashboards.ts +++ b/convex/dashboards.ts @@ -72,9 +72,9 @@ function queueSummaryLayout(widgetKey: string) { x: 0, y: 0, w: 12, - h: 6, + h: 8, minW: 8, - minH: 4, + minH: 7, static: false, } } diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index f3dc142..a8939d0 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [ title: "Relatórios", requiredRole: "staff", items: [ - { title: "Dashboards", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" }, + { title: "Painéis customizados", url: "/dashboards", icon: LayoutTemplate, requiredRole: "staff" }, { title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, diff --git a/src/components/dashboards/dashboard-builder.tsx b/src/components/dashboards/dashboard-builder.tsx index 7f72bba..f1a5902 100644 --- a/src/components/dashboards/dashboard-builder.tsx +++ b/src/components/dashboards/dashboard-builder.tsx @@ -100,6 +100,9 @@ import { getMetricDefinition, getMetricOptionsForRole } from "@/components/dashb const GRID_COLUMNS = 12 const DEFAULT_ROW_HEIGHT = 80 const MAX_ROWS = 32 +const QUEUE_SUMMARY_DEFAULT_ROWS = 8 +const QUEUE_SUMMARY_MIN_ROWS = 7 +const QUEUE_SUMMARY_MAX_ROWS = 12 type DashboardRecord = { id: Id<"dashboards"> @@ -233,7 +236,11 @@ const widgetSizePresets: Record< radar: { default: { w: 5, h: 6 }, min: { w: 4, h: 4 }, max: { w: 8, h: 9 } }, gauge: { default: { w: 4, h: 5 }, min: { w: 3, h: 4 }, max: { w: 6, h: 7 } }, table: { default: { w: 8, h: 8 }, min: { w: 6, h: 5 }, max: { w: 12, h: 12 } }, - "queue-summary": { default: { w: 12, h: 6 }, min: { w: 8, h: 4 }, max: { w: 12, h: 9 } }, + "queue-summary": { + default: { w: 12, h: QUEUE_SUMMARY_DEFAULT_ROWS }, + min: { w: 8, h: QUEUE_SUMMARY_MIN_ROWS }, + max: { w: 12, h: QUEUE_SUMMARY_MAX_ROWS }, + }, text: { default: { w: 6, h: 4 }, min: { w: 4, h: 3 }, max: { w: 10, h: 8 } }, } @@ -572,8 +579,10 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const [isDeletingDashboard, setIsDeletingDashboard] = useState(false) const fullscreenContainerRef = useRef(null) const autoFullscreenRef = useRef(false) + const tvFullscreenWarningShownRef = useRef(false) const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null) const ensureQueueSummaryRequestedRef = useRef(false) + const suppressQueueEnsureRef = useRef(false) const updateLayoutMutation = useMutation(api.dashboards.updateLayout) const updateFiltersMutation = useMutation(api.dashboards.updateFilters) @@ -632,8 +641,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } useEffect(() => { if (!dashboard || !convexUserId || !isStaff) return - if (widgets.length === 0) return - const queueIndex = widgets.findIndex((widget) => { + + const queueWidget = widgets.find((widget) => { const type = (widget.type ?? "").toLowerCase() if (type === "queue-summary") return true const configType = @@ -642,30 +651,44 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } : "" return configType === "queue-summary" }) - if (queueIndex === 0) { + + if (!queueWidget) { + if (suppressQueueEnsureRef.current) { + suppressQueueEnsureRef.current = false + } ensureQueueSummaryRequestedRef.current = false return } - if (queueIndex === -1 || queueIndex > 0) { - if (ensureQueueSummaryRequestedRef.current) return - ensureQueueSummaryRequestedRef.current = true - ensureQueueSummaryWidgetMutation({ - tenantId, - actorId: convexUserId as Id<"users">, - dashboardId: dashboard.id as Id<"dashboards">, - }).catch((error) => { + + if (suppressQueueEnsureRef.current) { + return + } + + const queueIndex = widgets.findIndex((widget) => widget.widgetKey === queueWidget.widgetKey) + const queueLayout = layoutState.find((item) => item.i === queueWidget.widgetKey) + const layoutTooSmall = + !queueLayout || + queueLayout.h < QUEUE_SUMMARY_MIN_ROWS || + (queueLayout.minH ?? 0) < QUEUE_SUMMARY_MIN_ROWS + const needsEnsure = queueIndex !== 0 || layoutTooSmall + + if (!needsEnsure) { + ensureQueueSummaryRequestedRef.current = false + return + } + + if (ensureQueueSummaryRequestedRef.current) return + ensureQueueSummaryRequestedRef.current = true + ensureQueueSummaryWidgetMutation({ + tenantId, + actorId: convexUserId as Id<"users">, + dashboardId: dashboard.id as Id<"dashboards">, + }) + .catch((error) => { console.error("[dashboards] Failed to ensure queue summary widget", error) ensureQueueSummaryRequestedRef.current = false }) - } - }, [ - dashboard, - widgets, - convexUserId, - isStaff, - tenantId, - ensureQueueSummaryWidgetMutation, - ]) + }, [dashboard, widgets, layoutState, convexUserId, isStaff, tenantId, ensureQueueSummaryWidgetMutation]) useEffect(() => { if (sections.length === 0) { @@ -690,47 +713,78 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } return () => document.removeEventListener("fullscreenchange", handleFullscreenChange) }, [setOpen, setOpenMobile]) - const handleToggleFullscreen = useCallback(async () => { - if (typeof document === "undefined") return - try { - if (!document.fullscreenElement) { + const handleToggleFullscreen = useCallback( + async (options?: { requestedByUser?: boolean }) => { + if (typeof document === "undefined") return false + const requestedByUser = Boolean(options?.requestedByUser) + const target = fullscreenContainerRef.current ?? document.documentElement + + const enterFullscreen = async () => { previousSidebarStateRef.current = { open, openMobile } if (isMobile) { setOpenMobile(false) } else { setOpen(false) } - const target = fullscreenContainerRef.current ?? document.documentElement - if (target && target.requestFullscreen) { + if (target?.requestFullscreen) { await target.requestFullscreen() + return true } - } else if (document.exitFullscreen) { - await document.exitFullscreen() + return false } - } catch (error) { - console.error("[dashboards] Failed to toggle fullscreen", error) - } - }, [isMobile, open, openMobile, setOpen, setOpenMobile]) + + try { + if (!document.fullscreenElement) { + return await enterFullscreen() + } + if (document.exitFullscreen) { + await document.exitFullscreen() + return true + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isPermissionError = + error instanceof DOMException + ? error.name === "NotAllowedError" || error.name === "SecurityError" || error.name === "TypeError" + : /permission/i.test(message) + if (!requestedByUser && isPermissionError) { + return false + } + console.error("[dashboards] Failed to toggle fullscreen", error) + } + return false + }, + [isMobile, open, openMobile, setOpen, setOpenMobile], + ) useEffect(() => { if (typeof document === "undefined") return if (enforceTv) { if (!document.fullscreenElement) { handleToggleFullscreen() - .then(() => { - autoFullscreenRef.current = true + .then((entered) => { + autoFullscreenRef.current = entered + if (entered) { + tvFullscreenWarningShownRef.current = false + } else if (!tvFullscreenWarningShownRef.current) { + tvFullscreenWarningShownRef.current = true + toast.info("Clique em \"Tela cheia\" para ativar o modo apresentação.") + } }) .catch(() => { autoFullscreenRef.current = false }) } else { autoFullscreenRef.current = true + tvFullscreenWarningShownRef.current = false } } else if (autoFullscreenRef.current && document.fullscreenElement) { document.exitFullscreen?.().catch(() => null) autoFullscreenRef.current = false + tvFullscreenWarningShownRef.current = false } else { autoFullscreenRef.current = false + tvFullscreenWarningShownRef.current = false } }, [enforceTv, handleToggleFullscreen]) @@ -952,6 +1006,12 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } const handleRemoveWidget = async (widget: DashboardWidgetRecord) => { if (!canEdit || !viewerId || !dashboard) return + const widgetType = (widget.type ?? "").toLowerCase() + const configType = + widget.config && typeof widget.config === "object" ? ((widget.config as WidgetConfig).type ?? "").toLowerCase() : "" + if (widgetType === "queue-summary" || configType === "queue-summary") { + suppressQueueEnsureRef.current = true + } try { await removeWidgetMutation({ tenantId, @@ -961,6 +1021,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } setLayoutState((prev) => prev.filter((item) => item.i !== widget.widgetKey)) toast.success("Widget removido do painel.") } catch (error) { + suppressQueueEnsureRef.current = false console.error(error) toast.error("Não foi possível remover o widget.") } @@ -1168,7 +1229,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" } 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} + onClick={() => handleToggleFullscreen({ requestedByUser: true })} > Sair da tela cheia @@ -1374,7 +1435,7 @@ function BuilderHeader({ totalWidgets: number onDeleteRequest?: () => void isFullscreen?: boolean - onToggleFullscreen: () => void + onToggleFullscreen: (options?: { requestedByUser?: boolean }) => void | Promise }) { const [name, setName] = useState(dashboard.name) const [description, setDescription] = useState(dashboard.description ?? "") @@ -1505,36 +1566,15 @@ function BuilderHeader({ {totalWidgets} bloco{totalWidgets === 1 ? "" : "s"} {isTvMode && hasSections ? ( - - + + Slide {activeSectionIndex + 1} de {totalSections} ) : null}
- {isEditingHeader ? ( -
- - -
- ) : null} -
+
{canEdit ? (
@@ -1545,7 +1585,7 @@ function BuilderHeader({ variant="ghost" size="sm" className="gap-2 rounded-full border border-slate-200 px-3 font-medium text-neutral-700 hover:border-slate-300 hover:bg-white" - onClick={onToggleFullscreen} + onClick={() => onToggleFullscreen({ requestedByUser: true })} > {isFullscreen ? : } {isFullscreen ? "Sair da tela cheia" : "Tela cheia"} @@ -1612,7 +1652,7 @@ function BuilderHeader({ ) : null}
+ {isEditingHeader ? ( +
+ + +
+ ) : null}
@@ -1873,7 +1934,7 @@ function BuilderWidgetCard({ diff --git a/src/components/dashboards/widget-renderer.tsx b/src/components/dashboards/widget-renderer.tsx index 8149e1d..2e05568 100644 --- a/src/components/dashboards/widget-renderer.tsx +++ b/src/components/dashboards/widget-renderer.tsx @@ -1029,7 +1029,7 @@ function renderQueueSummary({ ) : (
- +
)} diff --git a/src/components/tickets/ticket-custom-fields.tsx b/src/components/tickets/ticket-custom-fields.tsx index 6cb39f7..bd3e87e 100644 --- a/src/components/tickets/ticket-custom-fields.tsx +++ b/src/components/tickets/ticket-custom-fields.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useEffect, useMemo, useState, type ReactNode } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { format, parseISO } from "date-fns" @@ -32,6 +32,7 @@ type TicketCustomFieldsListProps = { record?: TicketCustomFieldRecord | null emptyMessage?: string className?: string + actionSlot?: ReactNode } const DEFAULT_FORM: TicketFormDefinition = { @@ -146,19 +147,28 @@ function normalizeFieldValue( } } -export function TicketCustomFieldsList({ record, emptyMessage, className }: TicketCustomFieldsListProps) { +export function TicketCustomFieldsList({ record, emptyMessage, className, actionSlot }: TicketCustomFieldsListProps) { const entries = useMemo(() => mapTicketCustomFields(record), [record]) + const hasAction = Boolean(actionSlot) if (entries.length === 0) { return ( -

- {emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."} -

+
+ {hasAction ? ( +
+ {actionSlot} +
+ ) : null} +

+ {emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."} +

+
) } return (
+ {hasAction ? actionSlot : null} {entries.map((entry) => (
(null) const [isSaving, setIsSaving] = useState(false) const [validationError, setValidationError] = useState(null) + const [currentFields, setCurrentFields] = useState(ticket.customFields) + + useEffect(() => { + setCurrentFields(ticket.customFields) + }, [ticket.customFields]) const initialValues = useMemo( - () => buildInitialValues(selectedForm.fields, ticket.customFields), - [selectedForm.fields, ticket.customFields] + () => buildInitialValues(selectedForm.fields, currentFields), + [selectedForm.fields, currentFields] ) useEffect(() => { @@ -283,11 +300,12 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP setIsSaving(true) setValidationError(null) try { - await updateCustomFields({ + const result = await updateCustomFields({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId, fields: payload, }) + setCurrentFields(result?.customFields ?? currentFields) toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" }) setEditorOpen(false) } catch (error) { @@ -298,281 +316,233 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP } } - const entries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields]) const hasConfiguredFields = selectedForm.fields.length > 0 - return ( -
-
-

Informações adicionais

- {canEdit && hasConfiguredFields ? ( - + ) : null + + const dialog = ( + + + + Editar campos personalizados + + Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos. + + + {hasConfiguredFields ? ( +
+ {selectedForm.fields.map((field) => renderFieldEditor(field))} +
+ ) : ( +

Nenhum campo configurado ainda.

+ )} + {validationError ?

{validationError}

: null} + + - ) : null} + + +
+
+ ) + + const listClassName = cn(variant === "inline" ? "sm:col-span-2 lg:col-span-3" : "", className) + + if (variant === "inline") { + return ( + <> + + {dialog} + + ) + } + + return ( +
+
+

Informações adicionais

- + {dialog} +
+ ) - - - - Editar campos personalizados - - Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos. - - +function renderFieldEditor(field: TicketFormFieldDefinition) { + const value = customFieldValues[field.id] + const fieldId = `ticket-custom-field-${field.id}` + const isTextarea = + field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao")) + const spanClass = + field.type === "boolean" || field.type === "date" || isTextarea ? "sm:col-span-2" : "" + const helpText = field.description ? ( +

{field.description}

+ ) : null - {hasConfiguredFields ? ( -
- {selectedForm.fields.map((field) => { - const value = customFieldValues[field.id] - const fieldId = `ticket-custom-field-${field.id}` - const isTextarea = - field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao")) - const spanClass = - isTextarea || field.type === "boolean" || field.type === "date" ? "sm:col-span-2" : "" - const helpText = field.description ? ( -

{field.description}

- ) : null - - if (field.type === "boolean") { - const isIndeterminate = value === null || value === undefined - return ( -
- { - if (!element) return - element.indeterminate = isIndeterminate - }} - onChange={(event) => handleFieldChange(field, event.target.checked)} - /> -
- - {helpText} - {!field.required ? ( - - ) : null} -
-
- ) - } - - if (field.type === "select") { - return ( - - - {field.label} {field.required ? * : null} - - - {helpText} - - ) - } - - if (field.type === "number") { - return ( - - - {field.label} {field.required ? * : null} - - handleFieldChange(field, event.target.value)} - /> - {helpText} - - ) - } - - if (field.type === "date") { - const parsedDate = - typeof value === "string" && value - ? parseISO(value) - : undefined - const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime())) - return ( - - - {field.label} {field.required ? * : null} - - setOpenCalendarField(open ? field.id : null)} - > - - - - - { - handleFieldChange( - field, - selected ? format(selected, "yyyy-MM-dd") : "" - ) - setOpenCalendarField(null) - }} - initialFocus - captionLayout="dropdown" - startMonth={new Date(1900, 0)} - endMonth={new Date(new Date().getFullYear() + 5, 11)} - locale={ptBR} - /> - - - {!field.required ? ( - - ) : null} - {helpText} - - ) - } - - if (isTextarea) { - return ( - - - {field.label} {field.required ? * : null} - -