feat: improve reports filters and ticket flows
This commit is contained in:
parent
9c74e10675
commit
15d11b6b12
29 changed files with 437 additions and 140 deletions
|
|
@ -631,7 +631,14 @@ export async function slaOverviewHandler(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const slaOverview = query({
|
export const slaOverview = query({
|
||||||
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
dateFrom: v.optional(v.string()),
|
||||||
|
dateTo: v.optional(v.string()),
|
||||||
|
},
|
||||||
handler: slaOverviewHandler,
|
handler: slaOverviewHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined
|
const range = searchParams.get("range") ?? undefined
|
||||||
const companyId = searchParams.get("companyId") ?? undefined
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
const context = await createConvexContext({
|
const context = await createConvexContext({
|
||||||
tenantId,
|
tenantId,
|
||||||
|
|
@ -26,7 +28,12 @@ export async function GET(request: Request) {
|
||||||
role: session.user.role.toUpperCase(),
|
role: session.user.role.toUpperCase(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const artifact = await buildBacklogWorkbook(context, { range, companyId: companyId ?? undefined })
|
const artifact = await buildBacklogWorkbook(context, {
|
||||||
|
range,
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
50
src/app/api/reports/category-insights.xlsx/route.ts
Normal file
50
src/app/api/reports/category-insights.xlsx/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { buildCategoryInsightsWorkbook, createConvexContext } from "@/server/report-exporters"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await assertAuthenticatedSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const range = searchParams.get("range") ?? undefined
|
||||||
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await createConvexContext({
|
||||||
|
tenantId,
|
||||||
|
name: session.user.name ?? session.user.email,
|
||||||
|
email: session.user.email,
|
||||||
|
avatarUrl: session.user.avatarUrl,
|
||||||
|
role: session.user.role.toUpperCase(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifact = await buildCategoryInsightsWorkbook(context, {
|
||||||
|
range,
|
||||||
|
companyId: companyId || undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(artifact.buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": artifact.mimeType,
|
||||||
|
"Content-Disposition": `attachment; filename="${artifact.fileName}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate category insights export", error)
|
||||||
|
return NextResponse.json({ error: "Falha ao gerar planilha de categorias" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined
|
const range = searchParams.get("range") ?? undefined
|
||||||
const companyId = searchParams.get("companyId") ?? undefined
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -26,7 +28,12 @@ export async function GET(request: Request) {
|
||||||
role: session.user.role.toUpperCase(),
|
role: session.user.role.toUpperCase(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const artifact = await buildCsatWorkbook(context, { range, companyId: companyId ?? undefined })
|
const artifact = await buildCsatWorkbook(context, {
|
||||||
|
range,
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export async function GET(request: Request) {
|
||||||
const range = searchParams.get("range") ?? undefined
|
const range = searchParams.get("range") ?? undefined
|
||||||
const q = searchParams.get("q")?.toLowerCase().trim() ?? ""
|
const q = searchParams.get("q")?.toLowerCase().trim() ?? ""
|
||||||
const companyId = searchParams.get("companyId") ?? ""
|
const companyId = searchParams.get("companyId") ?? ""
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -29,6 +31,8 @@ export async function GET(request: Request) {
|
||||||
range,
|
range,
|
||||||
companyId: companyId || undefined,
|
companyId: companyId || undefined,
|
||||||
search: q || undefined,
|
search: q || undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
})
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
|
|
|
||||||
54
src/app/api/reports/machine-category.xlsx/route.ts
Normal file
54
src/app/api/reports/machine-category.xlsx/route.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import { assertAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { buildMachineCategoryWorkbook, createConvexContext } from "@/server/report-exporters"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await assertAuthenticatedSession()
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const range = searchParams.get("range") ?? undefined
|
||||||
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const machineId = searchParams.get("machineId") ?? undefined
|
||||||
|
const userId = searchParams.get("userId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await createConvexContext({
|
||||||
|
tenantId,
|
||||||
|
name: session.user.name ?? session.user.email,
|
||||||
|
email: session.user.email,
|
||||||
|
avatarUrl: session.user.avatarUrl,
|
||||||
|
role: session.user.role.toUpperCase(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const artifact = await buildMachineCategoryWorkbook(context, {
|
||||||
|
range,
|
||||||
|
companyId: companyId || undefined,
|
||||||
|
machineId: machineId || undefined,
|
||||||
|
userId: userId || undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(artifact.buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": artifact.mimeType,
|
||||||
|
"Content-Disposition": `attachment; filename="${artifact.fileName}"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate machine category export", error)
|
||||||
|
return NextResponse.json({ error: "Falha ao gerar planilha de máquinas x categorias" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined
|
const range = searchParams.get("range") ?? undefined
|
||||||
const companyId = searchParams.get("companyId") ?? undefined
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -26,7 +28,12 @@ export async function GET(request: Request) {
|
||||||
role: session.user.role.toUpperCase(),
|
role: session.user.role.toUpperCase(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const artifact = await buildSlaWorkbook(context, { range, companyId: companyId ?? undefined })
|
const artifact = await buildSlaWorkbook(context, {
|
||||||
|
range,
|
||||||
|
companyId: companyId ?? undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
|
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
|
||||||
const companyId = searchParams.get("companyId") ?? undefined
|
const companyId = searchParams.get("companyId") ?? undefined
|
||||||
|
const dateFrom = searchParams.get("dateFrom") ?? undefined
|
||||||
|
const dateTo = searchParams.get("dateTo") ?? undefined
|
||||||
|
|
||||||
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
|
@ -29,6 +31,8 @@ export async function GET(request: Request) {
|
||||||
const artifact = await buildTicketsByChannelWorkbook(context, {
|
const artifact = await buildTicketsByChannelWorkbook(context, {
|
||||||
range,
|
range,
|
||||||
companyId: companyId ?? undefined,
|
companyId: companyId ?? undefined,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
})
|
})
|
||||||
|
|
||||||
return new NextResponse(artifact.buffer, {
|
return new NextResponse(artifact.buffer, {
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,6 @@ export default function NewTicketPage() {
|
||||||
}, [rawCustomers, viewerCustomer])
|
}, [rawCustomers, viewerCustomer])
|
||||||
|
|
||||||
const [subject, setSubject] = useState("")
|
const [subject, setSubject] = useState("")
|
||||||
const [summary, setSummary] = useState("")
|
|
||||||
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
|
const [priority, setPriority] = useState<TicketPriority>("MEDIUM")
|
||||||
const [channel, setChannel] = useState("MANUAL")
|
const [channel, setChannel] = useState("MANUAL")
|
||||||
const [queueName, setQueueName] = useState<string | null>(null)
|
const [queueName, setQueueName] = useState<string | null>(null)
|
||||||
|
|
@ -265,7 +264,6 @@ export default function NewTicketPage() {
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
tenantId: DEFAULT_TENANT_ID,
|
tenantId: DEFAULT_TENANT_ID,
|
||||||
subject: trimmedSubject,
|
subject: trimmedSubject,
|
||||||
summary: summary.trim() || undefined,
|
|
||||||
priority,
|
priority,
|
||||||
channel,
|
channel,
|
||||||
queueId,
|
queueId,
|
||||||
|
|
@ -321,18 +319,6 @@ export default function NewTicketPage() {
|
||||||
/>
|
/>
|
||||||
{subjectError ? <p className="text-xs font-medium text-red-500">{subjectError}</p> : null}
|
{subjectError ? <p className="text-xs font-medium text-red-500">{subjectError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-neutral-700" htmlFor="summary">
|
|
||||||
Resumo
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="summary"
|
|
||||||
className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-neutral-800 shadow-sm outline-none transition-colors focus-visible:border-[#00d6eb] focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20"
|
|
||||||
value={summary}
|
|
||||||
onChange={(event) => setSummary(event.target.value)}
|
|
||||||
placeholder="Resuma rapidamente o cenário ou impacto do ticket."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700">
|
<label className="flex items-center gap-1 text-sm font-medium text-neutral-700">
|
||||||
Descrição <span className="text-red-500">*</span>
|
Descrição <span className="text-red-500">*</span>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,10 @@ export function AgendaCalendarView({ events, range }: AgendaCalendarViewProps) {
|
||||||
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
|
const monthMatrix = useMemo(() => buildCalendarMatrix(currentMonth), [currentMonth])
|
||||||
const availableYears = useMemo(() => {
|
const availableYears = useMemo(() => {
|
||||||
const years = new Set<number>()
|
const years = new Set<number>()
|
||||||
years.add(new Date().getFullYear())
|
const baseYear = new Date().getFullYear()
|
||||||
|
for (let offset = 0; offset <= 5; offset += 1) {
|
||||||
|
years.add(baseYear + offset)
|
||||||
|
}
|
||||||
years.add(currentMonth.getFullYear())
|
years.add(currentMonth.getFullYear())
|
||||||
years.add(currentWeekStart.getFullYear())
|
years.add(currentWeekStart.getFullYear())
|
||||||
events.forEach((event) => {
|
events.forEach((event) => {
|
||||||
|
|
|
||||||
|
|
@ -49,9 +49,6 @@ export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
|
||||||
<span>{format(ticket.createdAt, "dd/MM/yyyy")}</span>
|
<span>{format(ticket.createdAt, "dd/MM/yyyy")}</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{ticket.subject}</h3>
|
<h3 className="mt-1 text-lg font-semibold text-neutral-900">{ticket.subject}</h3>
|
||||||
{ticket.summary ? (
|
|
||||||
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 text-right">
|
<div className="flex flex-col items-end gap-2 text-right">
|
||||||
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
|
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
|
||||||
|
|
|
||||||
|
|
@ -447,9 +447,6 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
|
<p className="text-sm text-neutral-500">Ticket #{ticket.reference}</p>
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
|
<h1 className="mt-1 text-2xl font-semibold text-neutral-900">{ticket.subject}</h1>
|
||||||
{ticket.summary ? (
|
|
||||||
<p className="mt-2 max-w-3xl text-sm text-neutral-600">{ticket.summary}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 text-sm">
|
<div className="flex flex-col items-end gap-2 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,6 @@ export function PortalTicketForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [subject, setSubject] = useState("")
|
const [subject, setSubject] = useState("")
|
||||||
const [summary, setSummary] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [categoryId, setCategoryId] = useState<string | null>(null)
|
const [categoryId, setCategoryId] = useState<string | null>(null)
|
||||||
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
|
const [subcategoryId, setSubcategoryId] = useState<string | null>(null)
|
||||||
|
|
@ -164,7 +163,6 @@ export function PortalTicketForm() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmedSubject = subject.trim()
|
const trimmedSubject = subject.trim()
|
||||||
const trimmedSummary = summary.trim()
|
|
||||||
const sanitizedDescription = sanitizeEditorHtml(description || "")
|
const sanitizedDescription = sanitizeEditorHtml(description || "")
|
||||||
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
|
const plainDescription = sanitizedDescription.replace(/<[^>]*>/g, "").trim()
|
||||||
if (plainDescription.length === 0) {
|
if (plainDescription.length === 0) {
|
||||||
|
|
@ -189,7 +187,6 @@ export function PortalTicketForm() {
|
||||||
actorId: viewerId,
|
actorId: viewerId,
|
||||||
tenantId,
|
tenantId,
|
||||||
subject: trimmedSubject,
|
subject: trimmedSubject,
|
||||||
summary: trimmedSummary || undefined,
|
|
||||||
priority: DEFAULT_PRIORITY,
|
priority: DEFAULT_PRIORITY,
|
||||||
channel: "MANUAL",
|
channel: "MANUAL",
|
||||||
queueId: undefined,
|
queueId: undefined,
|
||||||
|
|
@ -208,7 +205,7 @@ export function PortalTicketForm() {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject)
|
const htmlBody = sanitizedDescription || toHtml(trimmedSubject)
|
||||||
|
|
||||||
const typedAttachments = attachments.map((file) => ({
|
const typedAttachments = attachments.map((file) => ({
|
||||||
storageId: file.storageId as Id<"_storage">,
|
storageId: file.storageId as Id<"_storage">,
|
||||||
|
|
@ -298,19 +295,6 @@ export function PortalTicketForm() {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
|
||||||
<label htmlFor="summary" className="text-sm font-medium text-neutral-800">
|
|
||||||
Resumo (opcional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
id="summary"
|
|
||||||
value={summary}
|
|
||||||
onChange={(event) => setSummary(event.target.value)}
|
|
||||||
placeholder="Descreva rapidamente o que está acontecendo"
|
|
||||||
maxLength={600}
|
|
||||||
disabled={machineInactive || isSubmitting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
<label htmlFor="description" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
|
||||||
Detalhes <span className="text-red-500">*</span>
|
Detalhes <span className="text-red-500">*</span>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,15 @@ export function BacklogReport() {
|
||||||
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
|
return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev))
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/backlog.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, timeRange])
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -136,9 +145,7 @@ export function BacklogReport() {
|
||||||
setDateFrom(from)
|
setDateFrom(from)
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
exportHref={`/api/reports/backlog.xlsx?range=${timeRange}${
|
exportHref={exportHref}
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
|
||||||
}`}
|
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,15 @@ export function CategoryReport() {
|
||||||
|
|
||||||
const chartHeight = Math.max(240, chartData.length * 48)
|
const chartHeight = Math.max(240, chartData.length * 48)
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/category-insights.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, timeRange])
|
||||||
|
|
||||||
const summarySkeleton = (
|
const summarySkeleton = (
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{Array.from({ length: 3 }).map((_, index) => (
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
|
@ -203,6 +212,7 @@ export function CategoryReport() {
|
||||||
setDateFrom(from)
|
setDateFrom(from)
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
|
exportHref={exportHref}
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ export function CsatReport() {
|
||||||
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/csat.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, timeRange])
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -119,9 +128,7 @@ export function CsatReport() {
|
||||||
setDateFrom(from)
|
setDateFrom(from)
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
exportHref={`/api/reports/csat.xlsx?range=${timeRange}${
|
exportHref={exportHref}
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
|
||||||
}`}
|
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,16 @@ export function HoursReport() {
|
||||||
[filteredCompaniesWithComputed]
|
[filteredCompaniesWithComputed]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
const effectiveRange = timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
|
||||||
|
params.set("range", effectiveRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/hours-by-client.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, timeRange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
|
|
@ -230,9 +240,7 @@ export function HoursReport() {
|
||||||
showBillingFilter
|
showBillingFilter
|
||||||
billingFilter={billingFilter}
|
billingFilter={billingFilter}
|
||||||
onBillingFilterChange={(value) => setBillingFilter(value)}
|
onBillingFilterChange={(value) => setBillingFilter(value)}
|
||||||
exportHref={`/api/reports/hours-by-client.xlsx?range=${
|
exportHref={exportHref}
|
||||||
timeRange === "365d" || timeRange === "all" ? "90d" : timeRange
|
|
||||||
}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
|
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
dateFrom={dateFrom}
|
dateFrom={dateFrom}
|
||||||
dateTo={dateTo}
|
dateTo={dateTo}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,17 @@ export function MachineCategoryReport() {
|
||||||
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
|
const [selectedMachineId, setSelectedMachineId] = useState<string>("all")
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string>("all")
|
const [selectedUserId, setSelectedUserId] = useState<string>("all")
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (selectedMachineId !== "all") params.set("machineId", selectedMachineId)
|
||||||
|
if (selectedUserId !== "all") params.set("userId", selectedUserId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/machine-category.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, selectedMachineId, selectedUserId, timeRange])
|
||||||
|
|
||||||
const hours = useQuery(
|
const hours = useQuery(
|
||||||
api.reports.hoursByMachine,
|
api.reports.hoursByMachine,
|
||||||
enabled && selectedMachineId !== "all"
|
enabled && selectedMachineId !== "all"
|
||||||
|
|
@ -259,6 +270,7 @@ export function MachineCategoryReport() {
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
allowExtendedRanges
|
allowExtendedRanges
|
||||||
|
exportHref={exportHref}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="border-slate-200">
|
<Card className="border-slate-200">
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,15 @@ export function SlaReport() {
|
||||||
]
|
]
|
||||||
}, [companies])
|
}, [companies])
|
||||||
|
|
||||||
|
const exportHref = useMemo(() => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set("range", timeRange)
|
||||||
|
if (companyId !== "all") params.set("companyId", companyId)
|
||||||
|
if (dateFrom) params.set("dateFrom", dateFrom)
|
||||||
|
if (dateTo) params.set("dateTo", dateTo)
|
||||||
|
return `/api/reports/sla.xlsx?${params.toString()}`
|
||||||
|
}, [companyId, dateFrom, dateTo, timeRange])
|
||||||
|
|
||||||
const queueTotal = useMemo(
|
const queueTotal = useMemo(
|
||||||
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
|
||||||
[data]
|
[data]
|
||||||
|
|
@ -170,9 +179,7 @@ export function SlaReport() {
|
||||||
setDateFrom(from)
|
setDateFrom(from)
|
||||||
setDateTo(to)
|
setDateTo(to)
|
||||||
}}
|
}}
|
||||||
exportHref={`/api/reports/sla.xlsx?range=${timeRange}${
|
exportHref={exportHref}
|
||||||
companyId !== "all" ? `&companyId=${companyId}` : ""
|
|
||||||
}`}
|
|
||||||
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
onOpenScheduler={isAdmin ? () => setSchedulerOpen(true) : undefined}
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,14 @@ export function CloseTicketDialog({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
||||||
|
const sanitizedMessage = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
|
||||||
|
const hasContent = sanitizedMessage.replace(/<[^>]*>/g, "").trim().length > 0
|
||||||
|
if (!hasContent) {
|
||||||
|
toast.error("Inclua uma mensagem de encerramento antes de finalizar o ticket.", { id: "close-ticket" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
toast.dismiss("close-ticket")
|
toast.dismiss("close-ticket")
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
|
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
|
||||||
|
|
@ -630,18 +638,13 @@ export function CloseTicketDialog({
|
||||||
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
|
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
|
||||||
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
|
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
|
||||||
})
|
})
|
||||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
|
||||||
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
|
|
||||||
const hasContent = sanitized.replace(/<[^>]*>/g, "").trim().length > 0
|
|
||||||
if (hasContent) {
|
|
||||||
await addComment({
|
await addComment({
|
||||||
ticketId: ticketId as unknown as Id<"tickets">,
|
ticketId: ticketId as unknown as Id<"tickets">,
|
||||||
authorId: actorId,
|
authorId: actorId,
|
||||||
visibility: "PUBLIC",
|
visibility: "PUBLIC",
|
||||||
body: sanitized,
|
body: sanitizedMessage,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
})
|
})
|
||||||
}
|
|
||||||
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
|
toast.success("Ticket encerrado com sucesso!", { id: "close-ticket" })
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.localStorage.removeItem(draftStorageKey)
|
window.localStorage.removeItem(draftStorageKey)
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,6 @@ function TicketRow({ ticket }: { ticket: Ticket }) {
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1 pr-32 text-base font-semibold text-neutral-900">{ticket.subject}</span>
|
<span className="line-clamp-1 pr-32 text-base font-semibold text-neutral-900">{ticket.subject}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="line-clamp-2 pr-32 text-sm text-neutral-600">{ticket.summary ?? "Sem descrição"}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
|
||||||
{categoryBadges.length > 0 ? (
|
{categoryBadges.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,6 @@ const NO_COMPANY_VALUE = "__no_company__"
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
subject: z.string().default(""),
|
subject: z.string().default(""),
|
||||||
summary: z.string().optional(),
|
|
||||||
description: z.string().default(""),
|
description: z.string().default(""),
|
||||||
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
|
priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).default("MEDIUM"),
|
||||||
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
|
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
|
||||||
|
|
@ -131,7 +130,6 @@ export function NewTicketDialog({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
subject: "",
|
subject: "",
|
||||||
summary: "",
|
|
||||||
description: "",
|
description: "",
|
||||||
priority: "MEDIUM",
|
priority: "MEDIUM",
|
||||||
channel: "MANUAL",
|
channel: "MANUAL",
|
||||||
|
|
@ -551,7 +549,6 @@ export function NewTicketDialog({
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
tenantId: DEFAULT_TENANT_ID,
|
tenantId: DEFAULT_TENANT_ID,
|
||||||
subject: subjectTrimmed,
|
subject: subjectTrimmed,
|
||||||
summary: values.summary?.trim() || undefined,
|
|
||||||
priority: values.priority,
|
priority: values.priority,
|
||||||
channel: values.channel,
|
channel: values.channel,
|
||||||
queueId: sel?.id as Id<"queues"> | undefined,
|
queueId: sel?.id as Id<"queues"> | undefined,
|
||||||
|
|
@ -563,8 +560,8 @@ export function NewTicketDialog({
|
||||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||||
visitDate: visitDateTimestamp,
|
visitDate: visitDateTimestamp,
|
||||||
})
|
})
|
||||||
const summaryFallback = values.summary?.trim() ?? ""
|
const summaryFallback = subjectTrimmed
|
||||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : toHtml(summaryFallback)
|
||||||
const MAX_COMMENT_CHARS = 20000
|
const MAX_COMMENT_CHARS = 20000
|
||||||
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
|
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
|
||||||
if (plainForLimit.length > MAX_COMMENT_CHARS) {
|
if (plainForLimit.length > MAX_COMMENT_CHARS) {
|
||||||
|
|
@ -585,7 +582,6 @@ export function NewTicketDialog({
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
form.reset({
|
form.reset({
|
||||||
subject: "",
|
subject: "",
|
||||||
summary: "",
|
|
||||||
description: "",
|
description: "",
|
||||||
priority: "MEDIUM",
|
priority: "MEDIUM",
|
||||||
channel: "MANUAL",
|
channel: "MANUAL",
|
||||||
|
|
@ -704,21 +700,6 @@ export function NewTicketDialog({
|
||||||
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
|
<Input id="subject" {...form.register("subject")} placeholder="Ex.: Erro 500 no portal" />
|
||||||
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
|
<FieldError errors={form.formState.errors.subject ? [{ message: form.formState.errors.subject.message }] : []} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
|
|
||||||
<textarea
|
|
||||||
id="summary"
|
|
||||||
className="min-h-[96px] w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
|
|
||||||
maxLength={600}
|
|
||||||
{...form.register("summary")}
|
|
||||||
placeholder="Explique em poucas linhas o contexto do chamado."
|
|
||||||
onInput={(e) => {
|
|
||||||
const el = e.currentTarget
|
|
||||||
el.style.height = 'auto'
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel className="flex items-center gap-1">
|
<FieldLabel className="flex items-center gap-1">
|
||||||
Descrição <span className="text-destructive">*</span>
|
Descrição <span className="text-destructive">*</span>
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ export function PlayNextTicketCard({ context }: PlayNextTicketCardProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900">{ticket.subject}</h2>
|
||||||
<p className="text-sm text-neutral-600">{ticket.summary}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-600">
|
||||||
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
<Badge className={queueBadgeClass}>{ticket.queue ?? "Sem fila"}</Badge>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ function TicketRow({ ticket, entering }: { ticket: Ticket; entering: boolean })
|
||||||
<span className="line-clamp-1 text-[20px] font-semibold text-neutral-900 transition-colors group-hover:text-neutral-700">
|
<span className="line-clamp-1 text-[20px] font-semibold text-neutral-900 transition-colors group-hover:text-neutral-700">
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</span>
|
</span>
|
||||||
<p className="line-clamp-2 text-base text-neutral-600">{ticket.summary ?? "Sem descrição informada."}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
|
<div className="flex flex-wrap items-center gap-2 text-sm text-neutral-600">
|
||||||
<span className="font-semibold text-neutral-700">{requesterName}</span>
|
<span className="font-semibold text-neutral-700">{requesterName}</span>
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const changeQueue = useMutation(api.tickets.changeQueue)
|
const changeQueue = useMutation(api.tickets.changeQueue)
|
||||||
const changeRequester = useMutation(api.tickets.changeRequester)
|
const changeRequester = useMutation(api.tickets.changeRequester)
|
||||||
const updateSubject = useMutation(api.tickets.updateSubject)
|
const updateSubject = useMutation(api.tickets.updateSubject)
|
||||||
const updateSummary = useMutation(api.tickets.updateSummary)
|
|
||||||
const startWork = useMutation(api.tickets.startWork)
|
const startWork = useMutation(api.tickets.startWork)
|
||||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||||
|
|
@ -263,7 +262,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
|
const [assigneeState, setAssigneeState] = useState(ticket.assignee ?? null)
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [subject, setSubject] = useState(ticket.subject)
|
const [subject, setSubject] = useState(ticket.subject)
|
||||||
const [summary, setSummary] = useState(ticket.summary ?? "")
|
|
||||||
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
|
const [categorySelection, setCategorySelection] = useState<{ categoryId: string; subcategoryId: string }>(
|
||||||
{
|
{
|
||||||
categoryId: ticket.category?.id ?? "",
|
categoryId: ticket.category?.id ?? "",
|
||||||
|
|
@ -288,10 +286,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [customersInitialized, setCustomersInitialized] = useState(false)
|
const [customersInitialized, setCustomersInitialized] = useState(false)
|
||||||
const selectedCategoryId = categorySelection.categoryId
|
const selectedCategoryId = categorySelection.categoryId
|
||||||
const selectedSubcategoryId = categorySelection.subcategoryId
|
const selectedSubcategoryId = categorySelection.subcategoryId
|
||||||
const dirty = useMemo(
|
const dirty = useMemo(() => subject !== ticket.subject, [subject, ticket.subject])
|
||||||
() => subject !== ticket.subject || (summary ?? "") !== (ticket.summary ?? ""),
|
|
||||||
[subject, summary, ticket.subject, ticket.summary]
|
|
||||||
)
|
|
||||||
const currentCategoryId = ticket.category?.id ?? ""
|
const currentCategoryId = ticket.category?.id ?? ""
|
||||||
const currentSubcategoryId = ticket.subcategory?.id ?? ""
|
const currentSubcategoryId = ticket.subcategory?.id ?? ""
|
||||||
const categoryDirty = useMemo(() => {
|
const categoryDirty = useMemo(() => {
|
||||||
|
|
@ -616,13 +611,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if ((summary ?? "") !== (ticket.summary ?? "")) {
|
|
||||||
await updateSummary({
|
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
|
||||||
summary: (summary ?? "").trim(),
|
|
||||||
actorId: convexUserId as Id<"users">,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
toast.success("Cabeçalho atualizado!", { id: "save-header" })
|
||||||
}
|
}
|
||||||
setEditing(false)
|
setEditing(false)
|
||||||
|
|
@ -635,7 +623,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
setSubject(ticket.subject)
|
setSubject(ticket.subject)
|
||||||
setSummary(ticket.summary ?? "")
|
|
||||||
setCategorySelection({
|
setCategorySelection({
|
||||||
categoryId: currentCategoryId,
|
categoryId: currentCategoryId,
|
||||||
subcategoryId: currentSubcategoryId,
|
subcategoryId: currentSubcategoryId,
|
||||||
|
|
@ -1299,25 +1286,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
|
className="h-10 rounded-lg border border-slate-300 text-lg font-semibold text-neutral-900"
|
||||||
/>
|
/>
|
||||||
<textarea
|
|
||||||
value={summary}
|
|
||||||
onChange={(e) => {
|
|
||||||
const el = e.currentTarget
|
|
||||||
// auto-resize height based on content
|
|
||||||
el.style.height = 'auto'
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
setSummary(e.target.value)
|
|
||||||
}}
|
|
||||||
onInput={(e) => {
|
|
||||||
const el = e.currentTarget
|
|
||||||
el.style.height = 'auto'
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
maxLength={600}
|
|
||||||
className="w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
|
|
||||||
placeholder="Adicione um resumo opcional"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -1329,7 +1297,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{summary ? <p className="max-w-2xl text-sm text-neutral-600">{summary}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -218,9 +218,6 @@ export function TicketsTable({ tickets, enteringIds }: TicketsTableProps) {
|
||||||
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
|
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
|
||||||
{ticket.subject}
|
{ticket.subject}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-neutral-600 line-clamp-1 break-words">
|
|
||||||
{ticket.summary ?? "Sem resumo"}
|
|
||||||
</span>
|
|
||||||
{ticket.formTemplateLabel || ticket.formTemplate ? (
|
{ticket.formTemplateLabel || ticket.formTemplate ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">
|
<Badge className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-[11px] font-semibold text-sky-700">
|
||||||
|
|
|
||||||
|
|
@ -1041,7 +1041,17 @@ export function RichTextEditor({
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ minHeight }} className="rich-text p-3">
|
<div
|
||||||
|
style={{ minHeight }}
|
||||||
|
className="rich-text p-3"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (!editor) return
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
event.preventDefault()
|
||||||
|
editor.commands.focus("end")
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {
|
import {
|
||||||
addMinutes,
|
addMinutes,
|
||||||
|
addMonths,
|
||||||
endOfDay,
|
endOfDay,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
endOfWeek,
|
endOfWeek,
|
||||||
|
|
@ -95,17 +96,20 @@ export function buildAgendaDataset(tickets: Ticket[], filters: AgendaFilterState
|
||||||
completed: [] as AgendaTicketSummary[],
|
completed: [] as AgendaTicketSummary[],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upcomingWindowEnd = addMonths(now, 12)
|
||||||
|
|
||||||
for (const entry of enriched) {
|
for (const entry of enriched) {
|
||||||
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
|
const summary = buildSummary(entry.ticket, entry.schedule, entry.slaStatus)
|
||||||
const dueDate = entry.schedule.startAt
|
const dueDate = entry.schedule.startAt
|
||||||
const createdAt = entry.ticket.createdAt
|
const createdAt = entry.ticket.createdAt
|
||||||
const resolvedAt = entry.ticket.resolvedAt
|
const resolvedAt = entry.ticket.resolvedAt
|
||||||
|
|
||||||
if (dueDate && isWithinRange(dueDate, range)) {
|
if (dueDate && !entry.ticket.resolvedAt) {
|
||||||
if (!entry.ticket.resolvedAt && isAfter(dueDate, now)) {
|
const isFuture = isAfter(dueDate, now)
|
||||||
|
if (isFuture && isBefore(dueDate, upcomingWindowEnd)) {
|
||||||
summarySections.upcoming.push(summary)
|
summarySections.upcoming.push(summary)
|
||||||
}
|
}
|
||||||
if (!entry.ticket.resolvedAt && isBefore(dueDate, now)) {
|
if (!isFuture && isWithinRange(dueDate, range)) {
|
||||||
summarySections.overdue.push(summary)
|
summarySections.overdue.push(summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ export type ReportArtifact = {
|
||||||
type BaseOptions = {
|
type BaseOptions = {
|
||||||
range?: string
|
range?: string
|
||||||
companyId?: string | null
|
companyId?: string | null
|
||||||
|
dateFrom?: string | null
|
||||||
|
dateTo?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildHoursWorkbook(
|
export async function buildHoursWorkbook(
|
||||||
|
|
@ -61,6 +63,8 @@ export async function buildHoursWorkbook(
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
viewerId: ctx.viewerId,
|
viewerId: ctx.viewerId,
|
||||||
range: options.range,
|
range: options.range,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
|
|
@ -128,6 +132,174 @@ export async function buildHoursWorkbook(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildCategoryInsightsWorkbook(
|
||||||
|
ctx: ConvexReportContext,
|
||||||
|
options: BaseOptions
|
||||||
|
): Promise<ReportArtifact> {
|
||||||
|
const report = await ctx.client.query(api.reports.categoryInsights, {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
viewerId: ctx.viewerId,
|
||||||
|
range: options.range,
|
||||||
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const categories = (report.categories ?? []) as Array<{
|
||||||
|
name: string
|
||||||
|
total: number
|
||||||
|
resolved: number
|
||||||
|
topAgent: { name: string | null; total: number } | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
const totalTickets = typeof report.totalTickets === "number"
|
||||||
|
? report.totalTickets
|
||||||
|
: categories.reduce((acc, item) => acc + (item.total ?? 0), 0)
|
||||||
|
|
||||||
|
const summaryRows: Array<Array<unknown>> = [
|
||||||
|
["Relatório", "Categorias"],
|
||||||
|
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : options.range ?? "90d"],
|
||||||
|
["Total de tickets", totalTickets],
|
||||||
|
]
|
||||||
|
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||||
|
if (report.spotlight) {
|
||||||
|
summaryRows.push(["Destaque", report.spotlight.categoryName])
|
||||||
|
summaryRows.push(["Tickets no destaque", report.spotlight.tickets ?? 0])
|
||||||
|
if (report.spotlight.agentName) {
|
||||||
|
summaryRows.push(["Agente destaque", report.spotlight.agentName])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryRows = categories.map((category) => {
|
||||||
|
const resolvedRate = category.total > 0 ? (category.resolved / category.total) * 100 : null
|
||||||
|
return [
|
||||||
|
category.name,
|
||||||
|
category.total,
|
||||||
|
category.resolved,
|
||||||
|
resolvedRate === null ? null : Number(resolvedRate.toFixed(1)),
|
||||||
|
category.topAgent?.name ?? "Sem responsável",
|
||||||
|
category.topAgent?.total ?? 0,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const workbook = buildXlsxWorkbook([
|
||||||
|
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||||
|
{
|
||||||
|
name: "Categorias",
|
||||||
|
headers: ["Categoria", "Tickets", "Resolvidos", "% resolvidos", "Agente destaque", "Tickets agente"],
|
||||||
|
rows: categoryRows.length > 0 ? categoryRows : [["—", 0, 0, null, "—", 0]],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const fileName = `category-insights-${ctx.tenantId}-${report.rangeDays ?? "90"}d${
|
||||||
|
options.companyId ? `-${options.companyId}` : ""
|
||||||
|
}.xlsx`
|
||||||
|
|
||||||
|
const arrayBuffer = workbook.buffer.slice(workbook.byteOffset, workbook.byteOffset + workbook.byteLength) as ArrayBuffer
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
buffer: arrayBuffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildMachineCategoryWorkbook(
|
||||||
|
ctx: ConvexReportContext,
|
||||||
|
options: BaseOptions & { machineId?: string | null; userId?: string | null }
|
||||||
|
): Promise<ReportArtifact> {
|
||||||
|
const response = await ctx.client.query(api.reports.ticketsByMachineAndCategory, {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
viewerId: ctx.viewerId,
|
||||||
|
range: options.range,
|
||||||
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
machineId: options.machineId ? (options.machineId as Id<"machines">) : undefined,
|
||||||
|
userId: options.userId ? (options.userId as Id<"users">) : undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
|
}) as {
|
||||||
|
rangeDays: number
|
||||||
|
items: Array<{
|
||||||
|
date: string
|
||||||
|
machineHostname: string | null
|
||||||
|
machineId: string | null
|
||||||
|
companyName: string | null
|
||||||
|
categoryName: string
|
||||||
|
total: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = response.items ?? []
|
||||||
|
const summaryRows: Array<Array<unknown>> = [
|
||||||
|
["Relatório", "Máquinas x categorias"],
|
||||||
|
[
|
||||||
|
"Período",
|
||||||
|
response.rangeDays && response.rangeDays > 0
|
||||||
|
? `Últimos ${response.rangeDays} dias`
|
||||||
|
: options.range ?? "30d",
|
||||||
|
],
|
||||||
|
["Total de registros", items.length],
|
||||||
|
]
|
||||||
|
if (options.companyId) summaryRows.push(["EmpresaId", options.companyId])
|
||||||
|
if (options.machineId) summaryRows.push(["MáquinaId", options.machineId])
|
||||||
|
if (options.userId) summaryRows.push(["SolicitanteId", options.userId])
|
||||||
|
|
||||||
|
const machineAggregation = new Map<
|
||||||
|
string,
|
||||||
|
{ machine: string; company: string; total: number; categories: Set<string> }
|
||||||
|
>()
|
||||||
|
for (const item of items) {
|
||||||
|
const key = item.machineId ?? item.machineHostname ?? "sem-maquina"
|
||||||
|
if (!machineAggregation.has(key)) {
|
||||||
|
machineAggregation.set(key, {
|
||||||
|
machine: item.machineHostname ?? key,
|
||||||
|
company: item.companyName ?? "—",
|
||||||
|
total: 0,
|
||||||
|
categories: new Set<string>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const entry = machineAggregation.get(key)!
|
||||||
|
entry.total += item.total
|
||||||
|
entry.categories.add(item.categoryName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const perMachineRows = Array.from(machineAggregation.values())
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.map((entry) => [entry.machine, entry.company, entry.total, Array.from(entry.categories).join(", ")])
|
||||||
|
|
||||||
|
const occurrencesRows = items.map((item) => [
|
||||||
|
item.date,
|
||||||
|
item.machineHostname ?? "Sem identificação",
|
||||||
|
item.companyName ?? "—",
|
||||||
|
item.categoryName,
|
||||||
|
item.total,
|
||||||
|
])
|
||||||
|
|
||||||
|
const workbook = buildXlsxWorkbook([
|
||||||
|
{ name: "Resumo", headers: ["Item", "Valor"], rows: summaryRows },
|
||||||
|
{
|
||||||
|
name: "Máquinas",
|
||||||
|
headers: ["Máquina", "Empresa", "Tickets", "Categorias"],
|
||||||
|
rows: perMachineRows.length > 0 ? perMachineRows : [["—", "—", 0, "—"]],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ocorrências",
|
||||||
|
headers: ["Data", "Máquina", "Empresa", "Categoria", "Total"],
|
||||||
|
rows: occurrencesRows.length > 0 ? occurrencesRows : [["—", "—", "—", "—", 0]],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const fileName = `machine-category-${ctx.tenantId}-${options.range ?? response.rangeDays ?? "30"}d${
|
||||||
|
options.companyId ? `-${options.companyId}` : ""
|
||||||
|
}.xlsx`
|
||||||
|
|
||||||
|
const arrayBuffer = workbook.buffer.slice(workbook.byteOffset, workbook.byteOffset + workbook.byteLength) as ArrayBuffer
|
||||||
|
return {
|
||||||
|
fileName,
|
||||||
|
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
buffer: arrayBuffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildBacklogWorkbook(
|
export async function buildBacklogWorkbook(
|
||||||
ctx: ConvexReportContext,
|
ctx: ConvexReportContext,
|
||||||
options: BaseOptions
|
options: BaseOptions
|
||||||
|
|
@ -137,6 +309,8 @@ export async function buildBacklogWorkbook(
|
||||||
viewerId: ctx.viewerId,
|
viewerId: ctx.viewerId,
|
||||||
range: options.range,
|
range: options.range,
|
||||||
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryRows: Array<Array<unknown>> = [
|
const summaryRows: Array<Array<unknown>> = [
|
||||||
|
|
@ -200,6 +374,8 @@ export async function buildSlaWorkbook(
|
||||||
viewerId: ctx.viewerId,
|
viewerId: ctx.viewerId,
|
||||||
range: options.range,
|
range: options.range,
|
||||||
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryRows: Array<Array<unknown>> = [
|
const summaryRows: Array<Array<unknown>> = [
|
||||||
|
|
@ -248,6 +424,8 @@ export async function buildCsatWorkbook(
|
||||||
viewerId: ctx.viewerId,
|
viewerId: ctx.viewerId,
|
||||||
range: options.range,
|
range: options.range,
|
||||||
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const summaryRows: Array<Array<unknown>> = [
|
const summaryRows: Array<Array<unknown>> = [
|
||||||
|
|
@ -300,6 +478,8 @@ export async function buildTicketsByChannelWorkbook(
|
||||||
viewerId: ctx.viewerId,
|
viewerId: ctx.viewerId,
|
||||||
range: options.range,
|
range: options.range,
|
||||||
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
companyId: (options.companyId ?? undefined) as Id<"companies"> | undefined,
|
||||||
|
dateFrom: options.dateFrom ?? undefined,
|
||||||
|
dateTo: options.dateTo ?? undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const CHANNEL_PT: Record<string, string> = {
|
const CHANNEL_PT: Record<string, string> = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue