Atualiza dashboards e painel de tickets
This commit is contained in:
parent
c66ffa6e0b
commit
4655c7570a
9 changed files with 483 additions and 420 deletions
|
|
@ -72,9 +72,9 @@ function queueSummaryLayout(widgetKey: string) {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
w: 12,
|
w: 12,
|
||||||
h: 6,
|
h: 8,
|
||||||
minW: 8,
|
minW: 8,
|
||||||
minH: 4,
|
minH: 7,
|
||||||
static: false,
|
static: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const navigation: NavigationGroup[] = [
|
||||||
title: "Relatórios",
|
title: "Relatórios",
|
||||||
requiredRole: "staff",
|
requiredRole: "staff",
|
||||||
items: [
|
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: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
|
||||||
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
|
||||||
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, 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 GRID_COLUMNS = 12
|
||||||
const DEFAULT_ROW_HEIGHT = 80
|
const DEFAULT_ROW_HEIGHT = 80
|
||||||
const MAX_ROWS = 32
|
const MAX_ROWS = 32
|
||||||
|
const QUEUE_SUMMARY_DEFAULT_ROWS = 8
|
||||||
|
const QUEUE_SUMMARY_MIN_ROWS = 7
|
||||||
|
const QUEUE_SUMMARY_MAX_ROWS = 12
|
||||||
|
|
||||||
type DashboardRecord = {
|
type DashboardRecord = {
|
||||||
id: Id<"dashboards">
|
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 } },
|
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 } },
|
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 } },
|
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 } },
|
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 [isDeletingDashboard, setIsDeletingDashboard] = useState(false)
|
||||||
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
|
const fullscreenContainerRef = useRef<HTMLDivElement | null>(null)
|
||||||
const autoFullscreenRef = useRef(false)
|
const autoFullscreenRef = useRef(false)
|
||||||
|
const tvFullscreenWarningShownRef = useRef(false)
|
||||||
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
|
const previousSidebarStateRef = useRef<{ open: boolean; openMobile: boolean } | null>(null)
|
||||||
const ensureQueueSummaryRequestedRef = useRef(false)
|
const ensureQueueSummaryRequestedRef = useRef(false)
|
||||||
|
const suppressQueueEnsureRef = useRef(false)
|
||||||
|
|
||||||
const updateLayoutMutation = useMutation(api.dashboards.updateLayout)
|
const updateLayoutMutation = useMutation(api.dashboards.updateLayout)
|
||||||
const updateFiltersMutation = useMutation(api.dashboards.updateFilters)
|
const updateFiltersMutation = useMutation(api.dashboards.updateFilters)
|
||||||
|
|
@ -632,8 +641,8 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dashboard || !convexUserId || !isStaff) return
|
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()
|
const type = (widget.type ?? "").toLowerCase()
|
||||||
if (type === "queue-summary") return true
|
if (type === "queue-summary") return true
|
||||||
const configType =
|
const configType =
|
||||||
|
|
@ -642,30 +651,44 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
: ""
|
: ""
|
||||||
return configType === "queue-summary"
|
return configType === "queue-summary"
|
||||||
})
|
})
|
||||||
if (queueIndex === 0) {
|
|
||||||
|
if (!queueWidget) {
|
||||||
|
if (suppressQueueEnsureRef.current) {
|
||||||
|
suppressQueueEnsureRef.current = false
|
||||||
|
}
|
||||||
ensureQueueSummaryRequestedRef.current = false
|
ensureQueueSummaryRequestedRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (queueIndex === -1 || queueIndex > 0) {
|
|
||||||
|
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
|
if (ensureQueueSummaryRequestedRef.current) return
|
||||||
ensureQueueSummaryRequestedRef.current = true
|
ensureQueueSummaryRequestedRef.current = true
|
||||||
ensureQueueSummaryWidgetMutation({
|
ensureQueueSummaryWidgetMutation({
|
||||||
tenantId,
|
tenantId,
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
dashboardId: dashboard.id as Id<"dashboards">,
|
dashboardId: dashboard.id as Id<"dashboards">,
|
||||||
}).catch((error) => {
|
})
|
||||||
|
.catch((error) => {
|
||||||
console.error("[dashboards] Failed to ensure queue summary widget", error)
|
console.error("[dashboards] Failed to ensure queue summary widget", error)
|
||||||
ensureQueueSummaryRequestedRef.current = false
|
ensureQueueSummaryRequestedRef.current = false
|
||||||
})
|
})
|
||||||
}
|
}, [dashboard, widgets, layoutState, convexUserId, isStaff, tenantId, ensureQueueSummaryWidgetMutation])
|
||||||
}, [
|
|
||||||
dashboard,
|
|
||||||
widgets,
|
|
||||||
convexUserId,
|
|
||||||
isStaff,
|
|
||||||
tenantId,
|
|
||||||
ensureQueueSummaryWidgetMutation,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sections.length === 0) {
|
if (sections.length === 0) {
|
||||||
|
|
@ -690,47 +713,78 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange)
|
||||||
}, [setOpen, setOpenMobile])
|
}, [setOpen, setOpenMobile])
|
||||||
|
|
||||||
const handleToggleFullscreen = useCallback(async () => {
|
const handleToggleFullscreen = useCallback(
|
||||||
if (typeof document === "undefined") return
|
async (options?: { requestedByUser?: boolean }) => {
|
||||||
try {
|
if (typeof document === "undefined") return false
|
||||||
if (!document.fullscreenElement) {
|
const requestedByUser = Boolean(options?.requestedByUser)
|
||||||
|
const target = fullscreenContainerRef.current ?? document.documentElement
|
||||||
|
|
||||||
|
const enterFullscreen = async () => {
|
||||||
previousSidebarStateRef.current = { open, openMobile }
|
previousSidebarStateRef.current = { open, openMobile }
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setOpenMobile(false)
|
setOpenMobile(false)
|
||||||
} else {
|
} else {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
const target = fullscreenContainerRef.current ?? document.documentElement
|
if (target?.requestFullscreen) {
|
||||||
if (target && target.requestFullscreen) {
|
|
||||||
await target.requestFullscreen()
|
await target.requestFullscreen()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} else if (document.exitFullscreen) {
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
return await enterFullscreen()
|
||||||
|
}
|
||||||
|
if (document.exitFullscreen) {
|
||||||
await document.exitFullscreen()
|
await document.exitFullscreen()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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)
|
console.error("[dashboards] Failed to toggle fullscreen", error)
|
||||||
}
|
}
|
||||||
}, [isMobile, open, openMobile, setOpen, setOpenMobile])
|
return false
|
||||||
|
},
|
||||||
|
[isMobile, open, openMobile, setOpen, setOpenMobile],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
if (enforceTv) {
|
if (enforceTv) {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
handleToggleFullscreen()
|
handleToggleFullscreen()
|
||||||
.then(() => {
|
.then((entered) => {
|
||||||
autoFullscreenRef.current = true
|
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(() => {
|
.catch(() => {
|
||||||
autoFullscreenRef.current = false
|
autoFullscreenRef.current = false
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
autoFullscreenRef.current = true
|
autoFullscreenRef.current = true
|
||||||
|
tvFullscreenWarningShownRef.current = false
|
||||||
}
|
}
|
||||||
} else if (autoFullscreenRef.current && document.fullscreenElement) {
|
} else if (autoFullscreenRef.current && document.fullscreenElement) {
|
||||||
document.exitFullscreen?.().catch(() => null)
|
document.exitFullscreen?.().catch(() => null)
|
||||||
autoFullscreenRef.current = false
|
autoFullscreenRef.current = false
|
||||||
|
tvFullscreenWarningShownRef.current = false
|
||||||
} else {
|
} else {
|
||||||
autoFullscreenRef.current = false
|
autoFullscreenRef.current = false
|
||||||
|
tvFullscreenWarningShownRef.current = false
|
||||||
}
|
}
|
||||||
}, [enforceTv, handleToggleFullscreen])
|
}, [enforceTv, handleToggleFullscreen])
|
||||||
|
|
||||||
|
|
@ -952,6 +1006,12 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
|
|
||||||
const handleRemoveWidget = async (widget: DashboardWidgetRecord) => {
|
const handleRemoveWidget = async (widget: DashboardWidgetRecord) => {
|
||||||
if (!canEdit || !viewerId || !dashboard) return
|
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 {
|
try {
|
||||||
await removeWidgetMutation({
|
await removeWidgetMutation({
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -961,6 +1021,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
setLayoutState((prev) => prev.filter((item) => item.i !== widget.widgetKey))
|
setLayoutState((prev) => prev.filter((item) => item.i !== widget.widgetKey))
|
||||||
toast.success("Widget removido do painel.")
|
toast.success("Widget removido do painel.")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
suppressQueueEnsureRef.current = false
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Não foi possível remover o widget.")
|
toast.error("Não foi possível remover o widget.")
|
||||||
}
|
}
|
||||||
|
|
@ -1168,7 +1229,7 @@ export function DashboardBuilder({ dashboardId, editable = true, mode = "edit" }
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
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"
|
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" />
|
<Minimize2 className="size-4" />
|
||||||
Sair da tela cheia
|
Sair da tela cheia
|
||||||
|
|
@ -1374,7 +1435,7 @@ function BuilderHeader({
|
||||||
totalWidgets: number
|
totalWidgets: number
|
||||||
onDeleteRequest?: () => void
|
onDeleteRequest?: () => void
|
||||||
isFullscreen?: boolean
|
isFullscreen?: boolean
|
||||||
onToggleFullscreen: () => void
|
onToggleFullscreen: (options?: { requestedByUser?: boolean }) => void | Promise<boolean>
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState(dashboard.name)
|
const [name, setName] = useState(dashboard.name)
|
||||||
const [description, setDescription] = useState(dashboard.description ?? "")
|
const [description, setDescription] = useState(dashboard.description ?? "")
|
||||||
|
|
@ -1505,36 +1566,15 @@ function BuilderHeader({
|
||||||
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
|
{totalWidgets} bloco{totalWidgets === 1 ? "" : "s"}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isTvMode && hasSections ? (
|
{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">
|
<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" />
|
<MonitorPlay className="size-3.5 text-current" />
|
||||||
Slide {activeSectionIndex + 1} de {totalSections}
|
Slide {activeSectionIndex + 1} de {totalSections}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end">
|
||||||
{isEditingHeader ? (
|
<div className="flex flex-1 flex-col gap-3 sm:min-h-[180px]">
|
||||||
<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">
|
|
||||||
{canEdit ? (
|
{canEdit ? (
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
|
<WidgetPicker onSelect={onAddWidget} disabled={isAddingWidget} />
|
||||||
|
|
@ -1545,7 +1585,7 @@ function BuilderHeader({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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"
|
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 ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
|
||||||
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
|
{isFullscreen ? "Sair da tela cheia" : "Tela cheia"}
|
||||||
|
|
@ -1612,7 +1652,7 @@ function BuilderHeader({
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
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}
|
onClick={onDeleteRequest}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|
@ -1620,6 +1660,27 @@ function BuilderHeader({
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1873,7 +1934,7 @@ function BuilderWidgetCard({
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
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}
|
onClick={onRemove}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" />
|
<Trash2 className="size-3.5" />
|
||||||
|
|
|
||||||
|
|
@ -230,7 +230,7 @@ export function DashboardListView() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1">
|
<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">
|
<p className="text-sm text-muted-foreground">
|
||||||
Combine KPIs, gráficos, tabelas e texto em painéis dinâmicos com filtros globais.
|
Combine KPIs, gráficos, tabelas e texto em painéis dinâmicos com filtros globais.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -247,7 +247,7 @@ export function DashboardListView() {
|
||||||
<Sparkles className="size-5" />
|
<Sparkles className="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-muted-foreground">Use KPIs, filas e texto para contar a história da operação.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -317,12 +317,12 @@ export function DashboardListView() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex gap-2">
|
<CardFooter className="flex gap-2">
|
||||||
<Button asChild className="flex-1">
|
<Button asChild className="flex-1">
|
||||||
<Link href={`/dashboards/${dashboard.id}`}>Abrir dashboard</Link>
|
<Link href={`/dashboards/${dashboard.id}`}>Abrir painel</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
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)}
|
onClick={() => setDashboardToDelete(dashboard)}
|
||||||
aria-label={`Excluir ${dashboard.name}`}
|
aria-label={`Excluir ${dashboard.name}`}
|
||||||
>
|
>
|
||||||
|
|
@ -344,9 +344,9 @@ export function DashboardListView() {
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Excluir dashboard</DialogTitle>
|
<DialogTitle>Excluir painel</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter className="sm:justify-between">
|
<DialogFooter className="sm:justify-between">
|
||||||
|
|
@ -360,7 +360,7 @@ export function DashboardListView() {
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||||||
{isDeleting ? "Removendo..." : "Excluir dashboard"}
|
{isDeleting ? "Removendo..." : "Excluir painel"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1029,7 +1029,7 @@ function renderQueueSummary({
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div className="pb-1">
|
<div className="pb-1">
|
||||||
<TicketQueueSummaryCards queues={queues} />
|
<TicketQueueSummaryCards queues={queues} layout="compact" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</WidgetCard>
|
</WidgetCard>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||||
import { useMutation, useQuery } from "convex/react"
|
import { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { format, parseISO } from "date-fns"
|
import { format, parseISO } from "date-fns"
|
||||||
|
|
@ -32,6 +32,7 @@ type TicketCustomFieldsListProps = {
|
||||||
record?: TicketCustomFieldRecord | null
|
record?: TicketCustomFieldRecord | null
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
actionSlot?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_FORM: TicketFormDefinition = {
|
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 entries = useMemo(() => mapTicketCustomFields(record), [record])
|
||||||
|
const hasAction = Boolean(actionSlot)
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className={cn("text-sm text-neutral-500", className)}>
|
<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."}
|
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
||||||
|
{hasAction ? actionSlot : null}
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div
|
<div
|
||||||
key={entry.key}
|
key={entry.key}
|
||||||
|
|
@ -174,9 +184,11 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
|
||||||
|
|
||||||
type TicketCustomFieldsSectionProps = {
|
type TicketCustomFieldsSectionProps = {
|
||||||
ticket: TicketWithDetails
|
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 { convexUserId, role } = useAuth()
|
||||||
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
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 [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [validationError, setValidationError] = useState<string | null>(null)
|
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(
|
const initialValues = useMemo(
|
||||||
() => buildInitialValues(selectedForm.fields, ticket.customFields),
|
() => buildInitialValues(selectedForm.fields, currentFields),
|
||||||
[selectedForm.fields, ticket.customFields]
|
[selectedForm.fields, currentFields]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -283,11 +300,12 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
try {
|
try {
|
||||||
await updateCustomFields({
|
const result = await updateCustomFields({
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
actorId: viewerId,
|
actorId: viewerId,
|
||||||
fields: payload,
|
fields: payload,
|
||||||
})
|
})
|
||||||
|
setCurrentFields(result?.customFields ?? currentFields)
|
||||||
toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" })
|
toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" })
|
||||||
setEditorOpen(false)
|
setEditorOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -298,31 +316,24 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
|
|
||||||
const hasConfiguredFields = selectedForm.fields.length > 0
|
const hasConfiguredFields = selectedForm.fields.length > 0
|
||||||
|
|
||||||
return (
|
const editActionSlot =
|
||||||
<section className="space-y-3">
|
canEdit && hasConfiguredFields ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<button
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
|
|
||||||
{canEdit && hasConfiguredFields ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="inline-flex items-center gap-2"
|
|
||||||
onClick={() => setEditorOpen(true)}
|
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"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<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
|
Editar campos
|
||||||
</Button>
|
</span>
|
||||||
) : null}
|
<span className="text-xs font-medium text-neutral-500">Atualize informações personalizadas deste ticket.</span>
|
||||||
</div>
|
</button>
|
||||||
<TicketCustomFieldsList
|
) : null
|
||||||
record={ticket.customFields}
|
|
||||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
const dialog = (
|
||||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||||
<DialogContent className="max-w-3xl gap-4">
|
<DialogContent className="max-w-3xl gap-4">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -331,16 +342,63 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
|
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{hasConfiguredFields ? (
|
{hasConfiguredFields ? (
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{selectedForm.fields.map((field) => {
|
{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>
|
||||||
|
<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={currentFields}
|
||||||
|
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||||
|
actionSlot={editActionSlot}
|
||||||
|
/>
|
||||||
|
{dialog}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
|
||||||
|
function renderFieldEditor(field: TicketFormFieldDefinition) {
|
||||||
const value = customFieldValues[field.id]
|
const value = customFieldValues[field.id]
|
||||||
const fieldId = `ticket-custom-field-${field.id}`
|
const fieldId = `ticket-custom-field-${field.id}`
|
||||||
const isTextarea =
|
const isTextarea =
|
||||||
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
|
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
|
||||||
const spanClass =
|
const spanClass =
|
||||||
isTextarea || field.type === "boolean" || field.type === "date" ? "sm:col-span-2" : ""
|
field.type === "boolean" || field.type === "date" || isTextarea ? "sm:col-span-2" : ""
|
||||||
const helpText = field.description ? (
|
const helpText = field.description ? (
|
||||||
<p className="text-xs text-neutral-500">{field.description}</p>
|
<p className="text-xs text-neutral-500">{field.description}</p>
|
||||||
) : null
|
) : null
|
||||||
|
|
@ -392,10 +450,7 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
<FieldLabel className="flex items-center gap-1">
|
<FieldLabel className="flex items-center gap-1">
|
||||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Select
|
<Select value={typeof value === "string" ? value : ""} onValueChange={(selected) => handleFieldChange(field, selected)}>
|
||||||
value={typeof value === "string" ? value : ""}
|
|
||||||
onValueChange={(selected) => handleFieldChange(field, selected)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Selecione" />
|
<SelectValue placeholder="Selecione" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
@ -424,17 +479,11 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id={fieldId}
|
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
||||||
value={
|
|
||||||
typeof value === "number"
|
|
||||||
? String(value)
|
|
||||||
: typeof value === "string"
|
|
||||||
? value
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="rounded-lg border border-slate-300"
|
||||||
/>
|
/>
|
||||||
{helpText}
|
{helpText}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -442,83 +491,40 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field.type === "date") {
|
if (field.type === "date") {
|
||||||
const parsedDate =
|
|
||||||
typeof value === "string" && value
|
|
||||||
? parseISO(value)
|
|
||||||
: undefined
|
|
||||||
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
|
|
||||||
return (
|
return (
|
||||||
<Field key={field.id} className={cn("flex flex-col gap-1", spanClass)}>
|
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
|
||||||
<FieldLabel className="flex items-center gap-1">
|
<FieldLabel className="flex items-center gap-1">
|
||||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Popover
|
<Popover open={openCalendarField === field.id} onOpenChange={() => setOpenCalendarField(field.id)}>
|
||||||
open={openCalendarField === field.id}
|
|
||||||
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between gap-2 text-left font-normal",
|
"justify-between rounded-lg border border-slate-300 text-left font-normal",
|
||||||
!isValidDate && "text-muted-foreground"
|
!value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>
|
{value ? (
|
||||||
{isValidDate
|
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR })
|
||||||
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
|
) : (
|
||||||
: "Selecionar data"}
|
<span>Selecionar data</span>
|
||||||
</span>
|
)}
|
||||||
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
<CalendarIcon className="ml-2 size-4 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md">
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={isValidDate ? (parsedDate as Date) : undefined}
|
selected={value ? new Date(value as string) : undefined}
|
||||||
onSelect={(selected) => {
|
onSelect={(date) => {
|
||||||
handleFieldChange(
|
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
||||||
field,
|
|
||||||
selected ? format(selected, "yyyy-MM-dd") : ""
|
|
||||||
)
|
|
||||||
setOpenCalendarField(null)
|
setOpenCalendarField(null)
|
||||||
}}
|
}}
|
||||||
initialFocus
|
|
||||||
captionLayout="dropdown"
|
|
||||||
startMonth={new Date(1900, 0)}
|
|
||||||
endMonth={new Date(new Date().getFullYear() + 5, 11)}
|
|
||||||
locale={ptBR}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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}
|
{helpText}
|
||||||
</Field>
|
</Field>
|
||||||
)
|
)
|
||||||
|
|
@ -529,50 +535,14 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
||||||
<FieldLabel className="flex items-center gap-1">
|
<FieldLabel className="flex items-center gap-1">
|
||||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input
|
<Textarea
|
||||||
id={fieldId}
|
value={typeof value === "string" ? value : ""}
|
||||||
value={
|
|
||||||
typeof value === "string"
|
|
||||||
? value
|
|
||||||
: typeof value === "number"
|
|
||||||
? String(value)
|
|
||||||
: value != null
|
|
||||||
? String(value)
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||||
|
className="min-h-[88px] resize-none rounded-lg border border-slate-300"
|
||||||
/>
|
/>
|
||||||
{helpText}
|
{helpText}
|
||||||
</Field>
|
</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>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditorOpen(false)}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={handleSubmit} disabled={isSaving || !hasConfiguredFields}>
|
|
||||||
{isSaving ? <Spinner className="size-4" /> : null}
|
|
||||||
Salvar alterações
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
|
||||||
|
|
||||||
interface TicketDetailsPanelProps {
|
interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
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> = {
|
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||||
LOW: "Baixa",
|
LOW: "Baixa",
|
||||||
|
|
@ -49,12 +48,20 @@ function formatMinutes(value?: number | null) {
|
||||||
return `${value} min`
|
return `${value} min`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SummaryChipConfig = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
tone: SummaryTone
|
||||||
|
labelClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||||
|
|
||||||
const summaryChips = useMemo(() => {
|
const summaryChips = useMemo(() => {
|
||||||
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
|
const chips: SummaryChipConfig[] = [
|
||||||
{
|
{
|
||||||
key: "queue",
|
key: "queue",
|
||||||
label: "Fila",
|
label: "Fila",
|
||||||
|
|
@ -87,11 +94,12 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
if (ticket.formTemplateLabel) {
|
if (ticket.formTemplateLabel) {
|
||||||
chips.push({
|
chips.splice(Math.min(chips.length, 5), 0, {
|
||||||
key: "formTemplate",
|
key: "formTemplate",
|
||||||
label: "Tipo de solicitação",
|
label: "Fluxo",
|
||||||
value: ticket.formTemplateLabel,
|
value: ticket.formTemplateLabel,
|
||||||
tone: "info",
|
tone: "primary",
|
||||||
|
labelClassName: "text-white",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return chips
|
return chips
|
||||||
|
|
@ -117,7 +125,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
|
||||||
<CardDescription className="text-sm text-neutral-500">
|
<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>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{isAvulso ? (
|
{isAvulso ? (
|
||||||
|
|
@ -131,8 +139,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{summaryChips.map(({ key, label, value, tone }) => (
|
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
|
||||||
<SummaryChip key={key} label={label} value={value} tone={tone} />
|
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -190,8 +198,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<TicketCustomFieldsSection ticket={ticket} />
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
||||||
{agentTotals.length > 0 ? (
|
{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> = {
|
const toneClasses: Record<SummaryTone, string> = {
|
||||||
default: "border-slate-200 bg-white text-neutral-900",
|
default: "border-slate-200 bg-white text-neutral-900",
|
||||||
info: "border-sky-200 bg-sky-50 text-sky-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",
|
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||||
muted: "border-slate-200 bg-slate-50 text-neutral-600",
|
muted: "border-slate-200 bg-slate-50 text-neutral-600",
|
||||||
danger: "border-rose-200 bg-rose-50 text-rose-700",
|
danger: "border-rose-200 bg-rose-50 text-rose-700",
|
||||||
|
primary: "border-black bg-black text-white",
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
|
<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>
|
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
interface TicketQueueSummaryProps {
|
interface TicketQueueSummaryProps {
|
||||||
queues?: TicketQueueSummary[]
|
queues?: TicketQueueSummary[]
|
||||||
|
layout?: "default" | "compact"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQueueSummaryProps) {
|
||||||
const { convexUserId, isStaff } = useAuth()
|
const { convexUserId, isStaff } = useAuth()
|
||||||
const enabled = Boolean(isStaff && convexUserId)
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
const shouldFetch = Boolean(!queues && enabled)
|
const shouldFetch = Boolean(!queues && enabled)
|
||||||
|
|
@ -23,9 +24,16 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
) as TicketQueueSummary[] | undefined
|
) as TicketQueueSummary[] | undefined
|
||||||
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
|
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) {
|
if (!queues && shouldFetch && fromServer === undefined) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
// 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) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
<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="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||||
|
|
@ -33,11 +41,14 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
|
<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) => {
|
{data.map((queue) => {
|
||||||
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||||
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||||
|
|
@ -52,7 +63,8 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
{queue.name}
|
{queue.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4">
|
{/* 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="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">
|
<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">
|
<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">
|
||||||
|
|
@ -93,5 +105,6 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||||
import { StatusSelect } from "@/components/tickets/status-select"
|
import { StatusSelect } from "@/components/tickets/status-select"
|
||||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||||
|
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||||
import { CheckCircle2 } from "lucide-react"
|
import { CheckCircle2 } from "lucide-react"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -1568,6 +1569,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
|
<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}>
|
<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