Atualiza dashboards e painel de tickets
This commit is contained in:
parent
c66ffa6e0b
commit
4655c7570a
9 changed files with 483 additions and 420 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement | null>(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 })}
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
Sair da tela cheia
|
||||
|
|
@ -1374,7 +1435,7 @@ function BuilderHeader({
|
|||
totalWidgets: number
|
||||
onDeleteRequest?: () => void
|
||||
isFullscreen?: boolean
|
||||
onToggleFullscreen: () => void
|
||||
onToggleFullscreen: (options?: { requestedByUser?: boolean }) => void | Promise<boolean>
|
||||
}) {
|
||||
const [name, setName] = useState(dashboard.name)
|
||||
const [description, setDescription] = useState(dashboard.description ?? "")
|
||||
|
|
@ -1505,36 +1566,15 @@ function BuilderHeader({
|
|||
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
{isTvMode && hasSections ? (
|
||||
<Badge className="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-primary">
|
||||
<MonitorPlay className="size-3.5" />
|
||||
<Badge className="inline-flex items-center gap-2 rounded-full border border-sidebar-border bg-sidebar-accent px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-sidebar-accent-foreground shadow-sm">
|
||||
<MonitorPlay className="size-3.5 text-current" />
|
||||
Slide {activeSectionIndex + 1} de {totalSections}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||
{isEditingHeader ? (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-sm font-semibold text-neutral-700"
|
||||
onClick={handleCancelEditHeader}
|
||||
disabled={isSavingHeader}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/85"
|
||||
onClick={handleSaveHeader}
|
||||
disabled={isSavingHeader}
|
||||
>
|
||||
{isSavingHeader ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-1 flex-col gap-3 sm:min-h-[180px]">
|
||||
{canEdit ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
|
||||
|
|
@ -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 ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
|
||||
|
|
@ -1612,7 +1652,7 @@ function BuilderHeader({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50"
|
||||
className="gap-2 rounded-full border border-rose-200 px-3 font-medium text-rose-600 hover:bg-rose-50 hover:!text-rose-600 hover:[&>svg]:!text-rose-600"
|
||||
onClick={onDeleteRequest}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
|
|
@ -1620,6 +1660,27 @@ function BuilderHeader({
|
|||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{isEditingHeader ? (
|
||||
<div className="mt-auto flex flex-wrap items-center justify-end gap-2 pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-sm font-semibold text-neutral-700"
|
||||
onClick={handleCancelEditHeader}
|
||||
disabled={isSavingHeader}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-black/85"
|
||||
onClick={handleSaveHeader}
|
||||
disabled={isSavingHeader}
|
||||
>
|
||||
{isSavingHeader ? "Salvando..." : "Salvar"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1873,7 +1934,7 @@ function BuilderWidgetCard({
|
|||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-white text-rose-600 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 hover:text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-rose-200 bg-white text-rose-600 shadow-sm transition hover:border-rose-300 hover:bg-rose-50 hover:!text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200 [&>svg]:text-rose-600 hover:[&>svg]:!text-rose-600"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -230,7 +230,7 @@ export function DashboardListView() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-lg font-semibold">Dashboards personalizados</h2>
|
||||
<h2 className="text-lg font-semibold">Painéis customizados</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Combine KPIs, gráficos, tabelas e texto em painéis dinâmicos com filtros globais.
|
||||
</p>
|
||||
|
|
@ -247,7 +247,7 @@ export function DashboardListView() {
|
|||
<Sparkles className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-neutral-900">Nenhum dashboard ainda</h3>
|
||||
<h3 className="text-xl font-semibold text-neutral-900">Nenhum painel ainda</h3>
|
||||
<p className="text-sm text-muted-foreground">Use KPIs, filas e texto para contar a história da operação.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -317,12 +317,12 @@ export function DashboardListView() {
|
|||
</CardContent>
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button asChild className="flex-1">
|
||||
<Link href={`/dashboards/${dashboard.id}`}>Abrir dashboard</Link>
|
||||
<Link href={`/dashboards/${dashboard.id}`}>Abrir painel</Link>
|
||||
</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"
|
||||
className="shrink-0 size-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 hover:!text-rose-600 focus-visible:ring-rose-200 [&>svg]:transition [&>svg]:text-rose-600 hover:[&>svg]:!text-rose-600"
|
||||
onClick={() => setDashboardToDelete(dashboard)}
|
||||
aria-label={`Excluir ${dashboard.name}`}
|
||||
>
|
||||
|
|
@ -344,9 +344,9 @@ export function DashboardListView() {
|
|||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir dashboard</DialogTitle>
|
||||
<DialogTitle>Excluir painel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Essa ação remove o dashboard para toda a equipe. Confirme para continuar.
|
||||
Essa ação remove o painel para toda a equipe. Confirme para continuar.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
|
|
@ -360,7 +360,7 @@ export function DashboardListView() {
|
|||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||||
{isDeleting ? "Removendo..." : "Excluir dashboard"}
|
||||
{isDeleting ? "Removendo..." : "Excluir painel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1029,7 +1029,7 @@ function renderQueueSummary({
|
|||
<EmptyState />
|
||||
) : (
|
||||
<div className="pb-1">
|
||||
<TicketQueueSummaryCards queues={queues} />
|
||||
<TicketQueueSummaryCards queues={queues} layout="compact" />
|
||||
</div>
|
||||
)}
|
||||
</WidgetCard>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<p className={cn("text-sm text-neutral-500", className)}>
|
||||
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
|
||||
</p>
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{hasAction ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{actionSlot}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-sm text-neutral-500">
|
||||
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
||||
{hasAction ? actionSlot : null}
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
|
|
@ -174,9 +184,11 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
|
|||
|
||||
type TicketCustomFieldsSectionProps = {
|
||||
ticket: TicketWithDetails
|
||||
variant?: "card" | "inline"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
|
||||
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
|
||||
const { convexUserId, role } = useAuth()
|
||||
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
||||
|
||||
|
|
@ -229,10 +241,15 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
|||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [currentFields, setCurrentFields] = useState<TicketCustomFieldRecord | null | undefined>(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 (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
|
||||
{canEdit && hasConfiguredFields ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Editar campos
|
||||
const editActionSlot =
|
||||
canEdit && hasConfiguredFields ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
className="flex h-full min-h-[88px] w-full flex-col items-start justify-center gap-1 rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50/60 px-4 py-3 text-left text-sm font-semibold text-neutral-700 transition hover:border-slate-400 hover:bg-white"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
||||
<Pencil className="size-3.5 text-neutral-500" />
|
||||
Editar campos
|
||||
</span>
|
||||
<span className="text-xs font-medium text-neutral-500">Atualize informações personalizadas deste ticket.</span>
|
||||
</button>
|
||||
) : null
|
||||
|
||||
const dialog = (
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="max-w-3xl gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar campos personalizados</DialogTitle>
|
||||
<DialogDescription>
|
||||
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{hasConfiguredFields ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
|
||||
)}
|
||||
{validationError ? <p className="text-sm font-semibold text-rose-600">{validationError}</p> : null}
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)} disabled={isSaving}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
<Button onClick={handleSubmit} disabled={isSaving}>
|
||||
{isSaving ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const listClassName = cn(variant === "inline" ? "sm:col-span-2 lg:col-span-3" : "", className)
|
||||
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<>
|
||||
<TicketCustomFieldsList
|
||||
record={currentFields}
|
||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||
actionSlot={editActionSlot}
|
||||
className={listClassName}
|
||||
/>
|
||||
{dialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn("space-y-3", className)}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
|
||||
</div>
|
||||
<TicketCustomFieldsList
|
||||
record={ticket.customFields}
|
||||
<TicketCustomFieldsList
|
||||
record={currentFields}
|
||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||
actionSlot={editActionSlot}
|
||||
/>
|
||||
{dialog}
|
||||
</section>
|
||||
)
|
||||
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="max-w-3xl gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar campos personalizados</DialogTitle>
|
||||
<DialogDescription>
|
||||
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
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 ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
|
||||
{hasConfiguredFields ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{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 ? (
|
||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||
) : null
|
||||
|
||||
if (field.type === "boolean") {
|
||||
const isIndeterminate = value === null || value === undefined
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
|
||||
spanClass
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
ref={(element) => {
|
||||
if (!element) return
|
||||
element.indeterminate = isIndeterminate
|
||||
}}
|
||||
onChange={(event) => handleFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</label>
|
||||
{helpText}
|
||||
{!field.required ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
|
||||
onClick={() => handleClearField(field.id)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Remover valor
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onValueChange={(selected) => handleFieldChange(field, selected)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{!field.required ? (
|
||||
<SelectItem value="" className="text-neutral-500">
|
||||
Limpar seleção
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={
|
||||
typeof value === "number"
|
||||
? String(value)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const parsedDate =
|
||||
typeof value === "string" && value
|
||||
? parseISO(value)
|
||||
: undefined
|
||||
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex flex-col gap-1", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Popover
|
||||
open={openCalendarField === field.id}
|
||||
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between gap-2 text-left font-normal",
|
||||
!isValidDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isValidDate
|
||||
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
|
||||
: "Selecionar data"}
|
||||
</span>
|
||||
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? (parsedDate as Date) : undefined}
|
||||
onSelect={(selected) => {
|
||||
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}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!field.required ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
|
||||
onClick={() => handleFieldChange(field, "")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Limpar data
|
||||
</button>
|
||||
) : null}
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTextarea) {
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex-col", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: typeof value === "number"
|
||||
? String(value)
|
||||
: value != null
|
||||
? String(value)
|
||||
: ""
|
||||
}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-neutral-600">
|
||||
Nenhum campo personalizado configurado para este formulário.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError ? (
|
||||
<p className="text-sm font-medium text-destructive">{validationError}</p>
|
||||
if (field.type === "boolean") {
|
||||
const isIndeterminate = value === null || value === undefined
|
||||
return (
|
||||
<div
|
||||
key={field.id}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
|
||||
spanClass
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={fieldId}
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
||||
checked={Boolean(value)}
|
||||
ref={(element) => {
|
||||
if (!element) return
|
||||
element.indeterminate = isIndeterminate
|
||||
}}
|
||||
onChange={(event) => handleFieldChange(field, event.target.checked)}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</label>
|
||||
{helpText}
|
||||
{!field.required ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
|
||||
onClick={() => handleClearField(field.id)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Remover valor
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
if (field.type === "select") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Select value={typeof value === "string" ? value : ""} onValueChange={(selected) => handleFieldChange(field, selected)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
||||
{!field.required ? (
|
||||
<SelectItem value="" className="text-neutral-500">
|
||||
Limpar seleção
|
||||
</SelectItem>
|
||||
) : null}
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "number") {
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
placeholder="0"
|
||||
className="rounded-lg border border-slate-300"
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Popover open={openCalendarField === field.id} onOpenChange={() => setOpenCalendarField(field.id)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
"justify-between rounded-lg border border-slate-300 text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
Cancelar
|
||||
{value ? (
|
||||
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR })
|
||||
) : (
|
||||
<span>Selecionar data</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-2 size-4 opacity-50" />
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSaving || !hasConfiguredFields}>
|
||||
{isSaving ? <Spinner className="size-4" /> : null}
|
||||
Salvar alterações
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value ? new Date(value as string) : undefined}
|
||||
onSelect={(date) => {
|
||||
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
className="min-h-[88px] resize-none rounded-lg border border-slate-300"
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||
|
||||
interface TicketDetailsPanelProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger"
|
||||
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger" | "primary"
|
||||
|
||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||
LOW: "Baixa",
|
||||
|
|
@ -49,12 +48,20 @@ function formatMinutes(value?: number | null) {
|
|||
return `${value} min`
|
||||
}
|
||||
|
||||
type SummaryChipConfig = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
tone: SummaryTone
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
|
||||
const chips: SummaryChipConfig[] = [
|
||||
{
|
||||
key: "queue",
|
||||
label: "Fila",
|
||||
|
|
@ -87,11 +94,12 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
},
|
||||
]
|
||||
if (ticket.formTemplateLabel) {
|
||||
chips.push({
|
||||
chips.splice(Math.min(chips.length, 5), 0, {
|
||||
key: "formTemplate",
|
||||
label: "Tipo de solicitação",
|
||||
label: "Fluxo",
|
||||
value: ticket.formTemplateLabel,
|
||||
tone: "info",
|
||||
tone: "primary",
|
||||
labelClassName: "text-white",
|
||||
})
|
||||
}
|
||||
return chips
|
||||
|
|
@ -117,7 +125,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-500">
|
||||
Resumo do ticket, métricas de SLA e tempo dedicado pela equipe.
|
||||
Resumo do ticket, métricas de SLA, tempo dedicado e campos personalizados.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{isAvulso ? (
|
||||
|
|
@ -131,8 +139,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{summaryChips.map(({ key, label, value, tone }) => (
|
||||
<SummaryChip key={key} label={label} value={value} tone={tone} />
|
||||
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
|
||||
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -190,8 +198,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<TicketCustomFieldsSection ticket={ticket} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
||||
{agentTotals.length > 0 ? (
|
||||
|
|
@ -255,7 +261,17 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function SummaryChip({ label, value, tone = "default" }: { label: string; value: string; tone?: SummaryTone }) {
|
||||
function SummaryChip({
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
labelClassName,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tone?: SummaryTone
|
||||
labelClassName?: string
|
||||
}) {
|
||||
const toneClasses: Record<SummaryTone, string> = {
|
||||
default: "border-slate-200 bg-white text-neutral-900",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-900",
|
||||
|
|
@ -263,11 +279,12 @@ function SummaryChip({ label, value, tone = "default" }: { label: string; value:
|
|||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
muted: "border-slate-200 bg-slate-50 text-neutral-600",
|
||||
danger: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
primary: "border-black bg-black text-white",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">{label}</p>
|
||||
<p className={cn("text-[11px] font-semibold uppercase tracking-wide text-neutral-500", labelClassName)}>{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
|
||||
interface TicketQueueSummaryProps {
|
||||
queues?: TicketQueueSummary[]
|
||||
layout?: "default" | "compact"
|
||||
}
|
||||
|
||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||
export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQueueSummaryProps) {
|
||||
const { convexUserId, isStaff } = useAuth()
|
||||
const enabled = Boolean(isStaff && convexUserId)
|
||||
const shouldFetch = Boolean(!queues && enabled)
|
||||
|
|
@ -23,75 +24,87 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
|||
) as TicketQueueSummary[] | undefined
|
||||
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
|
||||
|
||||
const gridLayoutClass =
|
||||
layout === "compact"
|
||||
? "mx-auto grid w-full max-w-5xl grid-cols-1 gap-5 pb-3 md:grid-cols-2"
|
||||
: "grid w-full grid-cols-1 gap-5 pb-3 sm:grid-cols-2 md:grid-cols-3"
|
||||
|
||||
if (!queues && shouldFetch && fromServer === undefined) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
// Usa o mesmo grid do estado carregado para não “pular”
|
||||
<div className="h-full min-h-0 overflow-auto">
|
||||
<div className={gridLayoutClass}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
|
||||
{data.map((queue) => {
|
||||
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||
return (
|
||||
<Card
|
||||
key={queue.id}
|
||||
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
|
||||
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
|
||||
{queue.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
|
||||
Pendentes
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
|
||||
{queue.pending}
|
||||
</p>
|
||||
<div className="h-full min-h-0 overflow-auto">
|
||||
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
|
||||
<div className={gridLayoutClass}>
|
||||
{data.map((queue) => {
|
||||
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||
return (
|
||||
<Card
|
||||
key={queue.id}
|
||||
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
|
||||
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
|
||||
{queue.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{/* min-w-0 evita conteúdo interno empurrar a coluna */}
|
||||
<CardContent className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
|
||||
Pendentes
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
|
||||
{queue.pending}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Em andamento
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
||||
{queue.inProgress}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Pausados
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
||||
{queue.paused}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Em andamento
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
||||
{queue.inProgress}
|
||||
</p>
|
||||
<div className="pt-1">
|
||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||
<span className="mt-2 block text-xs text-neutral-500">
|
||||
{breachPercent}% dos chamados da fila estão fora do SLA
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-neutral-400">
|
||||
Em atraso: {queue.breached}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Pausados
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
||||
{queue.paused}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||
<span className="mt-2 block text-xs text-neutral-500">
|
||||
{breachPercent}% dos chamados da fila estão fora do SLA
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-neutral-400">
|
||||
Em atraso: {queue.breached}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { PrioritySelect } from "@/components/tickets/priority-select"
|
|||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
@ -1568,6 +1569,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />
|
||||
{editing ? (
|
||||
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue