feat: portal reopen, reports, templates and remote access
This commit is contained in:
parent
6a75a0a9ed
commit
52c03ff1cf
16 changed files with 1387 additions and 16 deletions
|
|
@ -1,3 +1,19 @@
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri_build::build()
|
let windows = tauri_build::WindowsAttributes::new().app_manifest(
|
||||||
|
r#"
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let attrs = tauri_build::Attributes::new().windows_attributes(windows);
|
||||||
|
|
||||||
|
tauri_build::try_build(attrs).expect("failed to run Tauri build script");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1317,6 +1317,150 @@ export const ticketsByChannel = query({
|
||||||
handler: ticketsByChannelHandler,
|
handler: ticketsByChannelHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type MachineCategoryDailyEntry = {
|
||||||
|
date: string
|
||||||
|
machineId: Id<"machines"> | null
|
||||||
|
machineHostname: string | null
|
||||||
|
companyId: Id<"companies"> | null
|
||||||
|
companyName: string | null
|
||||||
|
categoryId: Id<"ticketCategories"> | null
|
||||||
|
categoryName: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ticketsByMachineAndCategoryHandler(
|
||||||
|
ctx: QueryCtx,
|
||||||
|
{
|
||||||
|
tenantId,
|
||||||
|
viewerId,
|
||||||
|
range,
|
||||||
|
companyId,
|
||||||
|
}: { tenantId: string; viewerId: Id<"users">; range?: string; companyId?: Id<"companies"> }
|
||||||
|
) {
|
||||||
|
const viewer = await requireStaff(ctx, viewerId, tenantId)
|
||||||
|
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90
|
||||||
|
|
||||||
|
const end = new Date()
|
||||||
|
end.setUTCHours(0, 0, 0, 0)
|
||||||
|
const endMs = end.getTime() + ONE_DAY_MS
|
||||||
|
const startMs = endMs - days * ONE_DAY_MS
|
||||||
|
|
||||||
|
const tickets = await fetchScopedTicketsByCreatedRange(ctx, tenantId, viewer, startMs, endMs, companyId)
|
||||||
|
const categoriesMap = await fetchCategoryMap(ctx, tenantId)
|
||||||
|
|
||||||
|
const companyIds = new Set<Id<"companies">>()
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
if (ticket.companyId) {
|
||||||
|
companyIds.add(ticket.companyId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const companiesById = new Map<string, Doc<"companies"> | null>()
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(companyIds).map(async (id) => {
|
||||||
|
const doc = await ctx.db.get(id)
|
||||||
|
companiesById.set(String(id), doc ?? null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const aggregated = new Map<string, MachineCategoryDailyEntry>()
|
||||||
|
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null
|
||||||
|
if (createdAt === null || createdAt < startMs || createdAt >= endMs) continue
|
||||||
|
|
||||||
|
const hasMachine = Boolean(ticket.machineId) || Boolean(ticket.machineSnapshot)
|
||||||
|
if (!hasMachine) continue
|
||||||
|
|
||||||
|
const date = formatDateKey(createdAt)
|
||||||
|
const machineId = (ticket.machineId ?? null) as Id<"machines"> | null
|
||||||
|
const machineSnapshot = (ticket.machineSnapshot ?? null) as
|
||||||
|
| {
|
||||||
|
hostname?: string | null
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
const rawHostname =
|
||||||
|
typeof machineSnapshot?.hostname === "string" && machineSnapshot.hostname.trim().length > 0
|
||||||
|
? machineSnapshot.hostname.trim()
|
||||||
|
: null
|
||||||
|
const machineHostname = rawHostname ?? null
|
||||||
|
|
||||||
|
const snapshot = (ticket.slaSnapshot ?? null) as { categoryId?: Id<"ticketCategories">; categoryName?: string } | null
|
||||||
|
const rawCategoryId =
|
||||||
|
ticket.categoryId && typeof ticket.categoryId === "string"
|
||||||
|
? String(ticket.categoryId)
|
||||||
|
: snapshot?.categoryId
|
||||||
|
? String(snapshot.categoryId)
|
||||||
|
: null
|
||||||
|
const categoryName = resolveCategoryName(rawCategoryId, snapshot, categoriesMap)
|
||||||
|
|
||||||
|
const companyIdValue = (ticket.companyId ?? null) as Id<"companies"> | null
|
||||||
|
let companyName: string | null = null
|
||||||
|
if (companyIdValue) {
|
||||||
|
const company = companiesById.get(String(companyIdValue))
|
||||||
|
if (company?.name && company.name.trim().length > 0) {
|
||||||
|
companyName = company.name.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!companyName) {
|
||||||
|
const companySnapshot = (ticket.companySnapshot ?? null) as { name?: string | null } | null
|
||||||
|
if (companySnapshot?.name && companySnapshot.name.trim().length > 0) {
|
||||||
|
companyName = companySnapshot.name.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!companyName) {
|
||||||
|
companyName = "Sem empresa"
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = [
|
||||||
|
date,
|
||||||
|
machineId ? String(machineId) : "null",
|
||||||
|
machineHostname ?? "",
|
||||||
|
rawCategoryId ?? "uncategorized",
|
||||||
|
companyIdValue ? String(companyIdValue) : "null",
|
||||||
|
].join("|")
|
||||||
|
|
||||||
|
const existing = aggregated.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.total += 1
|
||||||
|
} else {
|
||||||
|
aggregated.set(key, {
|
||||||
|
date,
|
||||||
|
machineId,
|
||||||
|
machineHostname,
|
||||||
|
companyId: companyIdValue,
|
||||||
|
companyName,
|
||||||
|
categoryId: (rawCategoryId as Id<"ticketCategories"> | null) ?? null,
|
||||||
|
categoryName,
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(aggregated.values()).sort((a, b) => {
|
||||||
|
if (a.date !== b.date) return a.date.localeCompare(b.date)
|
||||||
|
const machineA = (a.machineHostname ?? "").toLowerCase()
|
||||||
|
const machineB = (b.machineHostname ?? "").toLowerCase()
|
||||||
|
if (machineA !== machineB) return machineA.localeCompare(machineB)
|
||||||
|
return a.categoryName.localeCompare(b.categoryName, "pt-BR")
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
rangeDays: days,
|
||||||
|
items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ticketsByMachineAndCategory = query({
|
||||||
|
args: {
|
||||||
|
tenantId: v.string(),
|
||||||
|
viewerId: v.id("users"),
|
||||||
|
range: v.optional(v.string()),
|
||||||
|
companyId: v.optional(v.id("companies")),
|
||||||
|
},
|
||||||
|
handler: ticketsByMachineAndCategoryHandler,
|
||||||
|
})
|
||||||
|
|
||||||
export async function hoursByClientHandler(
|
export async function hoursByClientHandler(
|
||||||
ctx: QueryCtx,
|
ctx: QueryCtx,
|
||||||
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
{ tenantId, viewerId, range }: { tenantId: string; viewerId: Id<"users">; range?: string }
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ const MAX_SUMMARY_CHARS = 600;
|
||||||
const MAX_COMMENT_CHARS = 20000;
|
const MAX_COMMENT_CHARS = 20000;
|
||||||
const DEFAULT_REOPEN_DAYS = 7;
|
const DEFAULT_REOPEN_DAYS = 7;
|
||||||
const MAX_REOPEN_DAYS = 14;
|
const MAX_REOPEN_DAYS = 14;
|
||||||
|
const VISIT_QUEUE_KEYWORDS = ["visita", "visitas", "in loco", "laboratório", "laboratorio", "lab"];
|
||||||
|
|
||||||
type AnyCtx = QueryCtx | MutationCtx;
|
type AnyCtx = QueryCtx | MutationCtx;
|
||||||
|
|
||||||
|
|
@ -2013,6 +2014,7 @@ export const create = mutation({
|
||||||
),
|
),
|
||||||
formTemplate: v.optional(v.string()),
|
formTemplate: v.optional(v.string()),
|
||||||
chatEnabled: v.optional(v.boolean()),
|
chatEnabled: v.optional(v.boolean()),
|
||||||
|
visitDate: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
const { user: actorUser, role } = await requireUser(ctx, args.actorId, args.tenantId)
|
||||||
|
|
@ -2151,6 +2153,23 @@ export const create = mutation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const slaFields = applySlaSnapshot(slaSnapshot, now)
|
const slaFields = applySlaSnapshot(slaSnapshot, now)
|
||||||
|
|
||||||
|
let resolvedQueueDoc: Doc<"queues"> | null = null
|
||||||
|
if (resolvedQueueId) {
|
||||||
|
const queueDoc = await ctx.db.get(resolvedQueueId)
|
||||||
|
if (queueDoc && queueDoc.tenantId === args.tenantId) {
|
||||||
|
resolvedQueueDoc = queueDoc as Doc<"queues">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueLabel = (resolvedQueueDoc?.slug ?? resolvedQueueDoc?.name ?? "").toLowerCase()
|
||||||
|
const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword))
|
||||||
|
const visitDueAt =
|
||||||
|
typeof args.visitDate === "number" && Number.isFinite(args.visitDate) ? args.visitDate : null
|
||||||
|
|
||||||
|
if (isVisitQueue && !visitDueAt) {
|
||||||
|
throw new ConvexError("Informe a data da visita para tickets da fila de visitas")
|
||||||
|
}
|
||||||
const id = await ctx.db.insert("tickets", {
|
const id = await ctx.db.insert("tickets", {
|
||||||
tenantId: args.tenantId,
|
tenantId: args.tenantId,
|
||||||
reference: nextRef,
|
reference: nextRef,
|
||||||
|
|
@ -2191,7 +2210,7 @@ export const create = mutation({
|
||||||
closedAt: undefined,
|
closedAt: undefined,
|
||||||
tags: [],
|
tags: [],
|
||||||
slaPolicyId: undefined,
|
slaPolicyId: undefined,
|
||||||
dueAt: undefined,
|
dueAt: visitDueAt && isVisitQueue ? visitDueAt : undefined,
|
||||||
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
customFields: normalizedCustomFields.length ? normalizedCustomFields : undefined,
|
||||||
...slaFields,
|
...slaFields,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
26
src/app/admin/report-templates/page.tsx
Normal file
26
src/app/admin/report-templates/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { ReportTemplatesManager } from "@/components/admin/reports/report-templates-manager"
|
||||||
|
import { requireAdminSession } from "@/lib/auth-server"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function AdminReportTemplatesPage() {
|
||||||
|
const session = await requireAdminSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
title="Templates de relatórios"
|
||||||
|
lead="Centralize a configuração dos templates de exportação de inventário por empresa ou globais."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||||
|
<ReportTemplatesManager tenantId={session.user.tenantId} />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
26
src/app/reports/machines/page.tsx
Normal file
26
src/app/reports/machines/page.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { AppShell } from "@/components/app-shell"
|
||||||
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
import { MachineCategoryReport } from "@/components/reports/machine-category-report"
|
||||||
|
import { requireAuthenticatedSession } from "@/lib/auth-server"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ReportsMachinesPage() {
|
||||||
|
await requireAuthenticatedSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={
|
||||||
|
<SiteHeader
|
||||||
|
title="Máquinas x categorias"
|
||||||
|
lead="Acompanhe quantos chamados cada máquina abriu por categoria, dia a dia, filtrando por empresa e período."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
|
||||||
|
<MachineCategoryReport />
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +18,9 @@ export default async function CommentTemplatesPage() {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CommentTemplatesManager />
|
<div className="mx-auto w-full max-w-6xl px-6 pb-12 lg:px-8">
|
||||||
|
<CommentTemplatesManager />
|
||||||
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,7 @@ type DeviceInventory = {
|
||||||
collaborator?: { email?: string; name?: string; role?: string }
|
collaborator?: { email?: string; name?: string; role?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeviceRemoteAccessEntry = {
|
export type DeviceRemoteAccessEntry = {
|
||||||
id: string | null
|
id: string | null
|
||||||
clientId: string
|
clientId: string
|
||||||
provider: string | null
|
provider: string | null
|
||||||
|
|
@ -478,7 +478,7 @@ function readText(record: Record<string, unknown>, ...keys: string[]): string |
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) {
|
export function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) {
|
||||||
if (!entry) return false
|
if (!entry) return false
|
||||||
const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase()
|
const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase()
|
||||||
if (provider.includes("rustdesk")) return true
|
if (provider.includes("rustdesk")) return true
|
||||||
|
|
@ -486,7 +486,7 @@ function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) {
|
||||||
return url.includes("rustdesk")
|
return url.includes("rustdesk")
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRustDeskUri(entry: DeviceRemoteAccessEntry) {
|
export function buildRustDeskUri(entry: DeviceRemoteAccessEntry) {
|
||||||
const identifier = (entry.identifier ?? "").replace(/\s+/g, "")
|
const identifier = (entry.identifier ?? "").replace(/\s+/g, "")
|
||||||
if (!identifier) return null
|
if (!identifier) return null
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
|
||||||
493
src/components/admin/reports/report-templates-manager.tsx
Normal file
493
src/components/admin/reports/report-templates-manager.tsx
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useMutation, useQuery } from "convex/react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
type DeviceExportTemplateListItem = {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
columns: DeviceInventoryColumnConfig[]
|
||||||
|
filters?: unknown | null
|
||||||
|
companyId: string | null
|
||||||
|
isDefault: boolean
|
||||||
|
isActive: boolean
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
createdBy?: string | null
|
||||||
|
updatedBy?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceFieldDef = {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportTemplatesManagerProps = {
|
||||||
|
tenantId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplatesManagerProps) {
|
||||||
|
const { convexUserId, session, isAdmin } = useAuth()
|
||||||
|
const tenantId = tenantIdProp ?? session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const [filterCompanyId, setFilterCompanyId] = useState<string>("all")
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string | "new">("new")
|
||||||
|
|
||||||
|
const canLoad = Boolean(convexUserId && isAdmin)
|
||||||
|
|
||||||
|
const templates = useQuery(
|
||||||
|
api.deviceExportTemplates.list,
|
||||||
|
canLoad
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
companyId: filterCompanyId !== "all" ? (filterCompanyId as Id<"companies">) : undefined,
|
||||||
|
includeInactive: true,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as DeviceExportTemplateListItem[] | undefined
|
||||||
|
|
||||||
|
const deviceFields = useQuery(
|
||||||
|
api.deviceFields.listForTenant,
|
||||||
|
canLoad
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as DeviceFieldDef[] | undefined
|
||||||
|
|
||||||
|
const companies = useQuery(
|
||||||
|
api.companies.list,
|
||||||
|
canLoad
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
const createTemplate = useMutation(api.deviceExportTemplates.create)
|
||||||
|
const updateTemplate = useMutation(api.deviceExportTemplates.update)
|
||||||
|
const removeTemplate = useMutation(api.deviceExportTemplates.remove)
|
||||||
|
|
||||||
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||||
|
if (!companies || companies.length === 0) return base
|
||||||
|
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
|
return [
|
||||||
|
base[0],
|
||||||
|
...sorted.map((company) => ({
|
||||||
|
value: company.id,
|
||||||
|
label: company.name,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}, [companies])
|
||||||
|
|
||||||
|
const availableColumns = useMemo(
|
||||||
|
() => {
|
||||||
|
const base = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({
|
||||||
|
key: meta.key,
|
||||||
|
label: meta.label,
|
||||||
|
group: "base" as const,
|
||||||
|
}))
|
||||||
|
const custom: { key: string; label: string; group: "custom" }[] =
|
||||||
|
(deviceFields ?? []).map((field) => ({
|
||||||
|
key: `custom:${field.key}`,
|
||||||
|
label: field.label,
|
||||||
|
group: "custom" as const,
|
||||||
|
}))
|
||||||
|
return [...base, ...custom].sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
|
||||||
|
},
|
||||||
|
[deviceFields]
|
||||||
|
)
|
||||||
|
|
||||||
|
const templateOptions = useMemo(
|
||||||
|
() => (templates ?? []).slice().sort((a, b) => a.name.localeCompare(b.name, "pt-BR")),
|
||||||
|
[templates]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [targetCompanyId, setTargetCompanyId] = useState<string | "all">("all")
|
||||||
|
const [isDefault, setIsDefault] = useState(false)
|
||||||
|
const [isActive, setIsActive] = useState(true)
|
||||||
|
const [selectedColumns, setSelectedColumns] = useState<string[]>([])
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
|
const selectedTemplate = useMemo(
|
||||||
|
() => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null,
|
||||||
|
[templateOptions, selectedTemplateId]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!templates) return
|
||||||
|
if (selectedTemplateId === "new") {
|
||||||
|
setName("")
|
||||||
|
setDescription("")
|
||||||
|
setTargetCompanyId("all")
|
||||||
|
setIsDefault(false)
|
||||||
|
setIsActive(true)
|
||||||
|
setSelectedColumns(DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tpl = templateOptions.find((t) => t.id === selectedTemplateId)
|
||||||
|
if (!tpl) {
|
||||||
|
setSelectedTemplateId("new")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setName(tpl.name)
|
||||||
|
setDescription(tpl.description ?? "")
|
||||||
|
setTargetCompanyId(tpl.companyId ?? "all")
|
||||||
|
setIsDefault(Boolean(tpl.isDefault))
|
||||||
|
setIsActive(tpl.isActive !== false)
|
||||||
|
const columnKeys = (tpl.columns ?? []).map((col) => col.key)
|
||||||
|
setSelectedColumns(columnKeys.length > 0 ? columnKeys : [])
|
||||||
|
}, [templates, templateOptions, selectedTemplateId])
|
||||||
|
|
||||||
|
const handleToggleColumn = (key: string, checked: boolean) => {
|
||||||
|
setSelectedColumns((prev) => {
|
||||||
|
const set = new Set(prev)
|
||||||
|
if (checked) {
|
||||||
|
set.add(key)
|
||||||
|
} else {
|
||||||
|
set.delete(key)
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setSelectedColumns(availableColumns.map((col) => col.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearColumns = () => {
|
||||||
|
setSelectedColumns([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!convexUserId) {
|
||||||
|
toast.error("Sincronize a sessão antes de salvar templates.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const trimmedName = name.trim()
|
||||||
|
if (trimmedName.length < 3) {
|
||||||
|
toast.error("Informe um nome para o template (mínimo 3 caracteres).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedColumns.length === 0) {
|
||||||
|
toast.error("Selecione ao menos uma coluna para o template.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const columnsPayload: DeviceInventoryColumnConfig[] = selectedColumns.map((key) => {
|
||||||
|
const meta = availableColumns.find((col) => col.key === key)
|
||||||
|
return { key, label: meta?.label }
|
||||||
|
})
|
||||||
|
const companyIdValue =
|
||||||
|
targetCompanyId !== "all" ? (targetCompanyId as unknown as Id<"companies">) : undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
if (!selectedTemplate || selectedTemplateId === "new") {
|
||||||
|
await createTemplate({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
name: trimmedName,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
columns: columnsPayload,
|
||||||
|
filters: undefined,
|
||||||
|
companyId: companyIdValue,
|
||||||
|
isDefault,
|
||||||
|
isActive,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await updateTemplate({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
templateId: selectedTemplate.id as Id<"deviceExportTemplates">,
|
||||||
|
name: trimmedName,
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
columns: columnsPayload,
|
||||||
|
filters: selectedTemplate.filters ?? undefined,
|
||||||
|
companyId: companyIdValue,
|
||||||
|
isDefault,
|
||||||
|
isActive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toast.success("Template salvo com sucesso.")
|
||||||
|
if (selectedTemplateId === "new") {
|
||||||
|
setSelectedTemplateId("new")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[report-templates] Failed to save template", error)
|
||||||
|
toast.error("Não foi possível salvar o template.")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!convexUserId || !selectedTemplate) return
|
||||||
|
try {
|
||||||
|
setIsDeleting(true)
|
||||||
|
await removeTemplate({
|
||||||
|
tenantId,
|
||||||
|
actorId: convexUserId as Id<"users">,
|
||||||
|
templateId: selectedTemplate.id as Id<"deviceExportTemplates">,
|
||||||
|
})
|
||||||
|
toast.success("Template removido com sucesso.")
|
||||||
|
setSelectedTemplateId("new")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[report-templates] Failed to delete template", error)
|
||||||
|
toast.error("Não foi possível remover o template.")
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canLoad) {
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Templates de relatórios</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Apenas administradores podem gerenciar templates de exportação.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templates || !companies) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-12 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1.6fr)]">
|
||||||
|
<Card className="border-slate-200 rounded-2xl shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Templates existentes</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Configure templates globais ou por empresa para exportar inventário de dispositivos.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<SearchableCombobox
|
||||||
|
value={filterCompanyId}
|
||||||
|
onValueChange={(value) => setFilterCompanyId(value ?? "all")}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Todas as empresas"
|
||||||
|
className="w-full min-w-56 md:w-64"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={selectedTemplateId === "new" ? "default" : "outline"}
|
||||||
|
onClick={() => setSelectedTemplateId("new")}
|
||||||
|
>
|
||||||
|
Novo template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{templateOptions.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
|
||||||
|
Nenhum template cadastrado ainda. Crie um template para padronizar as colunas das exportações de inventário.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{templateOptions.map((tpl) => (
|
||||||
|
<button
|
||||||
|
key={tpl.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedTemplateId(tpl.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition",
|
||||||
|
selectedTemplateId === tpl.id
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-slate-200 bg-white text-neutral-800 hover:border-slate-300 hover:bg-slate-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-semibold">{tpl.name}</span>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{tpl.companyId ? "Template específico por empresa" : "Template global"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{tpl.isDefault ? (
|
||||||
|
<Badge variant={selectedTemplateId === tpl.id ? "outline" : "secondary"} className="text-[10px] uppercase">
|
||||||
|
Padrão
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{tpl.isActive === false ? (
|
||||||
|
<Badge variant="outline" className="text-[10px] uppercase text-amber-700">
|
||||||
|
Inativo
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200 rounded-2xl shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">
|
||||||
|
{selectedTemplateId === "new" ? "Novo template" : "Editar template"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">
|
||||||
|
Nome do template <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="Ex.: Inventário padrão, Inventário reduzido..."
|
||||||
|
className="h-9 rounded-lg border-slate-300 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">Descrição</label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder="Contexto de uso, filtros sugeridos, etc."
|
||||||
|
rows={3}
|
||||||
|
className="rounded-lg border-slate-300 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">Escopo do template</label>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<SearchableCombobox
|
||||||
|
value={targetCompanyId}
|
||||||
|
onValueChange={(value) => setTargetCompanyId(value ?? "all")}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Todas as empresas"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-neutral-500">
|
||||||
|
Defina se este template será global ou específico para uma empresa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">Opções</label>
|
||||||
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
||||||
|
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(Boolean(v))} />
|
||||||
|
<span>Usar como padrão para este escopo</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
||||||
|
<Checkbox
|
||||||
|
checked={isActive}
|
||||||
|
onCheckedChange={(v) => setIsActive(Boolean(v))}
|
||||||
|
/>
|
||||||
|
<span>Template ativo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-neutral-800">Colunas incluídas</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={handleClearColumns}>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" onClick={handleSelectAll}>
|
||||||
|
Selecionar todas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
||||||
|
{availableColumns.length === 0 ? (
|
||||||
|
<p className="py-2 text-xs text-neutral-500">
|
||||||
|
Nenhuma coluna disponível. Verifique o inventário de dispositivos e os campos personalizados.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
availableColumns.map((col) => {
|
||||||
|
const checked = selectedColumns.includes(col.key)
|
||||||
|
const isCustom = col.group === "custom"
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={col.key}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-md bg-white px-2 py-1.5 text-xs text-neutral-800 shadow-[0_0_0_1px_rgba(148,163,184,0.3)] hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(v) => handleToggleColumn(col.key, Boolean(v))}
|
||||||
|
className="size-4"
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{col.label}
|
||||||
|
{isCustom ? " (campo personalizado)" : ""}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
||||||
|
<span>{selectedColumns.length} colunas selecionadas</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{templateOptions.length} templates cadastrados</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedTemplate && selectedTemplateId !== "new" ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? "Removendo..." : "Remover"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? "Salvando..." : "Salvar template"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,7 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "Clientes", url: "/reports/company", icon: Building2, requiredRole: "staff" },
|
{ title: "Clientes", url: "/reports/company", icon: Building2, requiredRole: "staff" },
|
||||||
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
|
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
|
||||||
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
|
||||||
|
{ title: "Máquinas x categorias", url: "/reports/machines", icon: MonitorCog, requiredRole: "staff" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -114,6 +115,8 @@ const navigation: NavigationGroup[] = [
|
||||||
{ title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true },
|
{ title: "Equipe", url: "/admin", icon: LayoutDashboard, requiredRole: "admin", exact: true },
|
||||||
{ title: "Empresas", url: "/admin/companies", icon: Building, requiredRole: "admin" },
|
{ title: "Empresas", url: "/admin/companies", icon: Building, requiredRole: "admin" },
|
||||||
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" },
|
||||||
|
{ title: "Templates de comentários", url: "/settings/templates", icon: LayoutTemplate, requiredRole: "admin" },
|
||||||
|
{ title: "Templates de relatórios", url: "/admin/report-templates", icon: LayoutTemplate, requiredRole: "admin" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import Link from "next/link"
|
||||||
import { useAction, useMutation, useQuery } from "convex/react"
|
import { useAction, useMutation, useQuery } from "convex/react"
|
||||||
import { format, formatDistanceToNow } from "date-fns"
|
import { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
import { Download, FileIcon, MessageCircle, X } from "lucide-react"
|
import { Download, FileIcon, MessageCircle, RotateCcw, X } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
|
@ -63,8 +63,9 @@ type ClientTimelineEntry = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
const { convexUserId, session, isCustomer, machineContext } = useAuth()
|
const { convexUserId, session, role, isStaff, isCustomer, machineContext } = useAuth()
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
const reopenTicket = useMutation(api.tickets.reopenTicket)
|
||||||
const getFileUrl = useAction(api.files.getUrl)
|
const getFileUrl = useAction(api.files.getUrl)
|
||||||
const [comment, setComment] = useState("")
|
const [comment, setComment] = useState("")
|
||||||
const [attachments, setAttachments] = useState<
|
const [attachments, setAttachments] = useState<
|
||||||
|
|
@ -75,6 +76,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
[attachments]
|
[attachments]
|
||||||
)
|
)
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{ url: string; name: string; type?: string } | null>(null)
|
const [previewAttachment, setPreviewAttachment] = useState<{ url: string; name: string; type?: string } | null>(null)
|
||||||
|
const [isReopening, setIsReopening] = useState(false)
|
||||||
const isPreviewImage = useMemo(() => {
|
const isPreviewImage = useMemo(() => {
|
||||||
if (!previewAttachment) return false
|
if (!previewAttachment) return false
|
||||||
const type = previewAttachment.type ?? ""
|
const type = previewAttachment.type ?? ""
|
||||||
|
|
@ -273,6 +275,50 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
[ticket]
|
[ticket]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const viewerId = convexUserId ?? null
|
||||||
|
const viewerRole = (role ?? "").toLowerCase()
|
||||||
|
const reopenDeadline = ticket?.reopenDeadline ?? null
|
||||||
|
const isRequester =
|
||||||
|
Boolean(ticket?.requester?.id) && Boolean(viewerId) && ticket?.requester?.id === viewerId
|
||||||
|
const reopenWindowActive = reopenDeadline ? reopenDeadline > Date.now() : false
|
||||||
|
const canReopenTicket =
|
||||||
|
!!ticket &&
|
||||||
|
ticket.status === "RESOLVED" &&
|
||||||
|
reopenWindowActive &&
|
||||||
|
(isStaff || viewerRole === "manager" || isRequester)
|
||||||
|
const reopenDeadlineLabel = useMemo(() => {
|
||||||
|
if (!reopenDeadline) return null
|
||||||
|
try {
|
||||||
|
return new Date(reopenDeadline).toLocaleString("pt-BR")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [reopenDeadline])
|
||||||
|
|
||||||
|
const handleReopenTicket = useCallback(async () => {
|
||||||
|
if (!ticket || !viewerId) {
|
||||||
|
toast.error("Não foi possível identificar o usuário atual.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!reopenWindowActive) {
|
||||||
|
toast.error("O prazo para reabrir este ticket expirou.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const toastId = "portal-ticket-reopen"
|
||||||
|
toast.dismiss(toastId)
|
||||||
|
setIsReopening(true)
|
||||||
|
toast.loading("Reabrindo ticket...", { id: toastId })
|
||||||
|
try {
|
||||||
|
await reopenTicket({ ticketId: ticket.id as Id<"tickets">, actorId: viewerId as Id<"users"> })
|
||||||
|
toast.success("Ticket reaberto com sucesso!", { id: toastId })
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível reabrir o ticket.", { id: toastId })
|
||||||
|
} finally {
|
||||||
|
setIsReopening(false)
|
||||||
|
}
|
||||||
|
}, [reopenTicket, ticket, viewerId, reopenWindowActive])
|
||||||
|
|
||||||
if (ticketRaw === undefined) {
|
if (ticketRaw === undefined) {
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
|
@ -306,6 +352,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
|
|
||||||
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
|
const createdAt = format(ticket.createdAt, "dd 'de' MMMM 'de' yyyy 'às' HH:mm", { locale: ptBR })
|
||||||
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
const updatedAgo = formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (machineInactive) {
|
if (machineInactive) {
|
||||||
|
|
@ -388,7 +435,29 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
|
||||||
) : null}
|
) : 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">
|
||||||
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold" />
|
<div className="flex items-center gap-2">
|
||||||
|
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold" />
|
||||||
|
{canReopenTicket ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white text-xs font-semibold text-neutral-700 hover:bg-slate-50"
|
||||||
|
onClick={handleReopenTicket}
|
||||||
|
disabled={isReopening}
|
||||||
|
>
|
||||||
|
{isReopening ? (
|
||||||
|
<Spinner className="size-4 text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="size-4 text-neutral-700" />
|
||||||
|
)}
|
||||||
|
Reabrir chamado
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{canReopenTicket && reopenDeadlineLabel ? (
|
||||||
|
<p className="text-xs text-neutral-500">Prazo para reabrir: {reopenDeadlineLabel}</p>
|
||||||
|
) : null}
|
||||||
{!isCustomer ? (
|
{!isCustomer ? (
|
||||||
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold ${priorityTone[ticket.priority]}`}>
|
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold ${priorityTone[ticket.priority]}`}>
|
||||||
{priorityLabel[ticket.priority]}
|
{priorityLabel[ticket.priority]}
|
||||||
|
|
|
||||||
291
src/components/reports/machine-category-report.tsx
Normal file
291
src/components/reports/machine-category-report.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"
|
||||||
|
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { DEFAULT_TENANT_ID } from "@/lib/constants"
|
||||||
|
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { ReportsFilterToolbar } from "@/components/reports/report-filter-toolbar"
|
||||||
|
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||||
|
import { formatDateDM } from "@/lib/utils"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
type MachineCategoryDailyItem = {
|
||||||
|
date: string
|
||||||
|
machineId: string | null
|
||||||
|
machineHostname: string | null
|
||||||
|
companyId: string | null
|
||||||
|
companyName: string | null
|
||||||
|
categoryId: string | null
|
||||||
|
categoryName: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MachineCategoryReportData = {
|
||||||
|
rangeDays: number
|
||||||
|
items: MachineCategoryDailyItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MachineCategoryReport() {
|
||||||
|
const [timeRange, setTimeRange] = useState("30d")
|
||||||
|
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
|
||||||
|
const { session, convexUserId, isStaff } = useAuth()
|
||||||
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
||||||
|
|
||||||
|
const enabled = Boolean(isStaff && convexUserId)
|
||||||
|
|
||||||
|
const data = useQuery(
|
||||||
|
api.reports.ticketsByMachineAndCategory,
|
||||||
|
enabled
|
||||||
|
? ({
|
||||||
|
tenantId,
|
||||||
|
viewerId: convexUserId as Id<"users">,
|
||||||
|
range: timeRange,
|
||||||
|
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as MachineCategoryReportData | undefined
|
||||||
|
|
||||||
|
const companies = useQuery(
|
||||||
|
api.companies.list,
|
||||||
|
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
|
||||||
|
) as Array<{ id: Id<"companies">; name: string }> | undefined
|
||||||
|
|
||||||
|
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
|
||||||
|
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
|
||||||
|
if (!companies || companies.length === 0) {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
|
||||||
|
return [
|
||||||
|
base[0],
|
||||||
|
...sorted.map((company) => ({
|
||||||
|
value: company.id,
|
||||||
|
label: company.name,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}, [companies])
|
||||||
|
|
||||||
|
const items = useMemo(() => data?.items ?? [], [data])
|
||||||
|
|
||||||
|
const totals = useMemo(
|
||||||
|
() =>
|
||||||
|
items.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc.totalTickets += item.total
|
||||||
|
acc.machines.add(item.machineId ?? item.machineHostname ?? "sem-maquina")
|
||||||
|
acc.categories.add(item.categoryName)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{
|
||||||
|
totalTickets: 0,
|
||||||
|
machines: new Set<string>(),
|
||||||
|
categories: new Set<string>(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dailySeries = useMemo(
|
||||||
|
() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (const item of items) {
|
||||||
|
const current = map.get(item.date) ?? 0
|
||||||
|
map.set(item.date, current + item.total)
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([date, total]) => ({ date, total }))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
},
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tableRows = useMemo(
|
||||||
|
() =>
|
||||||
|
[...items].sort((a, b) => {
|
||||||
|
if (a.date !== b.date) return b.date.localeCompare(a.date)
|
||||||
|
const machineA = (a.machineHostname ?? "").toLowerCase()
|
||||||
|
const machineB = (b.machineHostname ?? "").toLowerCase()
|
||||||
|
if (machineA !== machineB) return machineA.localeCompare(machineB)
|
||||||
|
return a.categoryName.localeCompare(b.categoryName, "pt-BR")
|
||||||
|
}),
|
||||||
|
[items]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">Máquinas x categorias</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Este relatório está disponível apenas para a equipe interna.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-16 rounded-2xl" />
|
||||||
|
<Skeleton className="h-64 rounded-2xl" />
|
||||||
|
<Skeleton className="h-80 rounded-2xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<ReportsFilterToolbar
|
||||||
|
companyId={companyId}
|
||||||
|
onCompanyChange={(value) => setCompanyId(value)}
|
||||||
|
companyOptions={companyOptions}
|
||||||
|
timeRange={timeRange as "90d" | "30d" | "7d"}
|
||||||
|
onTimeRangeChange={(value) => setTimeRange(value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Chamados analisados
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Total de tickets com máquina vinculada no período.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||||
|
{totals.totalTickets}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Máquinas únicas
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Quantidade de dispositivos diferentes com chamados no período.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||||
|
{totals.machines.size}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">
|
||||||
|
Categorias
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Categorias distintas associadas aos tickets dessas máquinas.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-3xl font-semibold text-neutral-900">
|
||||||
|
{totals.categories.size}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">
|
||||||
|
Volume diário por máquina (total)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Quantidade de chamados com máquina vinculada, somando todas as categorias, por dia.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{dailySeries.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ChartContainer config={{}} className="aspect-auto h-[260px] w-full">
|
||||||
|
<BarChart data={dailySeries} margin={{ top: 8, left: 20, right: 20, bottom: 32 }}>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
tickFormatter={(value) => formatDateDM(new Date(String(value)))}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
className="w-[180px]"
|
||||||
|
labelFormatter={(value) => formatDateDM(new Date(String(value)))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="total" fill="var(--chart-1)" radius={[4, 4, 0, 0]} name="Chamados" />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-slate-200">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg font-semibold text-neutral-900">
|
||||||
|
Detalhamento diário por máquina e categoria
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-neutral-600">
|
||||||
|
Cada linha representa o total de chamados abertos em uma data específica, agrupados por
|
||||||
|
máquina e categoria.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{tableRows.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
|
||||||
|
Nenhum ticket com máquina vinculada foi encontrado para o período selecionado.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-slate-200">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-neutral-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">Data</th>
|
||||||
|
<th className="px-4 py-3 text-left">Máquina</th>
|
||||||
|
<th className="px-4 py-3 text-left">Empresa</th>
|
||||||
|
<th className="px-4 py-3 text-left">Categoria</th>
|
||||||
|
<th className="px-4 py-3 text-right">Chamados</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tableRows.map((row, index) => {
|
||||||
|
const machineLabel =
|
||||||
|
row.machineHostname && row.machineHostname.trim().length > 0
|
||||||
|
? row.machineHostname
|
||||||
|
: "Sem hostname"
|
||||||
|
const companyLabel = row.companyName ?? "Sem empresa"
|
||||||
|
return (
|
||||||
|
<tr key={`${row.date}-${machineLabel}-${row.categoryName}-${index}`} className="border-t border-slate-100">
|
||||||
|
<td className="px-4 py-2 text-neutral-800">
|
||||||
|
{formatDateDM(new Date(`${row.date}T00:00:00Z`))}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-neutral-800">{machineLabel}</td>
|
||||||
|
<td className="px-4 py-2 text-neutral-700">{companyLabel}</td>
|
||||||
|
<td className="px-4 py-2 text-neutral-700">{row.categoryName}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-semibold text-neutral-900">{row.total}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -378,7 +378,8 @@ export function SlaReport() {
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-lg font-semibold text-neutral-900">Produtividade por agente</CardTitle>
|
<CardTitle className="text-lg font-semibold text-neutral-900">Produtividade por agente</CardTitle>
|
||||||
<CardDescription className="text-neutral-600">
|
<CardDescription className="text-neutral-600">
|
||||||
Chamados resolvidos no período por agente (top 10) e horas trabalhadas.
|
Atendimentos e tempo gasto por agente no período selecionado
|
||||||
|
(7, 30 ou 90 dias), com filtro por empresa.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -405,12 +406,42 @@ export function SlaReport() {
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-xs text-neutral-500">Horas trabalhadas (estimado)</p>
|
<p className="text-xs text-neutral-500">Atendimentos, tempos médios e horas trabalhadas</p>
|
||||||
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200">
|
<ul className="divide-y divide-slate-200 overflow-hidden rounded-md border border-slate-200">
|
||||||
{agents.items.slice(0, 10).map((a) => (
|
{agents.items.slice(0, 10).map((a) => (
|
||||||
<li key={a.agentId} className="flex items-center justify-between px-3 py-2 text-sm">
|
<li key={a.agentId} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
<span className="truncate">{a.name || a.email || 'Agente'}</span>
|
<div className="flex w-full flex-col gap-1">
|
||||||
<span className="text-neutral-700">{formatHoursCompact(a.workedHours)}</span>
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-medium text-neutral-900">{a.name || a.email || "Agente"}</span>
|
||||||
|
<span className="text-xs font-semibold text-neutral-700">
|
||||||
|
{formatHoursCompact(a.workedHours)} h trabalhadas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-neutral-500">
|
||||||
|
<span>
|
||||||
|
Em atendimento:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">{a.open}</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Resolvidos:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">{a.resolved}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-neutral-500">
|
||||||
|
<span>
|
||||||
|
1ª resposta média:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">
|
||||||
|
{formatMinutes(a.avgFirstResponseMinutes)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Resolução média:{" "}
|
||||||
|
<span className="font-medium text-neutral-800">
|
||||||
|
{formatMinutes(a.avgResolutionMinutes)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import { priorityStyles } from "@/lib/ticket-priority-style"
|
||||||
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
||||||
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
||||||
import { Calendar as CalendarIcon } from "lucide-react"
|
import { Calendar as CalendarIcon } from "lucide-react"
|
||||||
|
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||||
|
|
||||||
type TriggerVariant = "button" | "card"
|
type TriggerVariant = "button" | "card"
|
||||||
|
|
||||||
|
|
@ -108,6 +109,7 @@ const schema = z.object({
|
||||||
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"),
|
||||||
queueName: z.string().nullable().optional(),
|
queueName: z.string().nullable().optional(),
|
||||||
|
visitDate: z.string().nullable().optional(),
|
||||||
assigneeId: z.string().nullable().optional(),
|
assigneeId: z.string().nullable().optional(),
|
||||||
companyId: z.string().optional(),
|
companyId: z.string().optional(),
|
||||||
requesterId: z.string().min(1, "Selecione um solicitante"),
|
requesterId: z.string().min(1, "Selecione um solicitante"),
|
||||||
|
|
@ -221,6 +223,7 @@ export function NewTicketDialog({
|
||||||
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
|
||||||
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
||||||
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
||||||
|
const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
|
||||||
|
|
||||||
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
|
||||||
|
|
||||||
|
|
@ -270,12 +273,29 @@ export function NewTicketDialog({
|
||||||
)
|
)
|
||||||
const priorityValue = form.watch("priority") as TicketPriority
|
const priorityValue = form.watch("priority") as TicketPriority
|
||||||
const queueValue = form.watch("queueName") ?? "NONE"
|
const queueValue = form.watch("queueName") ?? "NONE"
|
||||||
|
const visitDateValue = form.watch("visitDate") ?? null
|
||||||
const assigneeValue = form.watch("assigneeId") ?? null
|
const assigneeValue = form.watch("assigneeId") ?? null
|
||||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||||
const requesterValue = form.watch("requesterId") ?? ""
|
const requesterValue = form.watch("requesterId") ?? ""
|
||||||
const categoryIdValue = form.watch("categoryId")
|
const categoryIdValue = form.watch("categoryId")
|
||||||
const subcategoryIdValue = form.watch("subcategoryId")
|
const subcategoryIdValue = form.watch("subcategoryId")
|
||||||
const isSubmitted = form.formState.isSubmitted
|
const isSubmitted = form.formState.isSubmitted
|
||||||
|
|
||||||
|
const normalizedQueueName =
|
||||||
|
typeof queueValue === "string" && queueValue !== "NONE" ? queueValue.toLowerCase() : ""
|
||||||
|
const isVisitQueue = useMemo(
|
||||||
|
() => VISIT_KEYWORDS.some((keyword) => normalizedQueueName.includes(keyword)),
|
||||||
|
[normalizedQueueName]
|
||||||
|
)
|
||||||
|
const visitDate = useMemo(() => {
|
||||||
|
if (!visitDateValue) return null
|
||||||
|
try {
|
||||||
|
return parseISO(visitDateValue)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [visitDateValue])
|
||||||
|
|
||||||
const companyOptions = useMemo(() => {
|
const companyOptions = useMemo(() => {
|
||||||
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
|
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
|
||||||
companies.forEach((company) => {
|
companies.forEach((company) => {
|
||||||
|
|
@ -429,6 +449,7 @@ export function NewTicketDialog({
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setAssigneeInitialized(false)
|
setAssigneeInitialized(false)
|
||||||
setOpenCalendarField(null)
|
setOpenCalendarField(null)
|
||||||
|
setVisitDatePickerOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (assigneeInitialized) return
|
if (assigneeInitialized) return
|
||||||
|
|
@ -490,6 +511,17 @@ export function NewTicketDialog({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentQueueName = values.queueName ?? ""
|
||||||
|
const isVisitQueueOnSubmit =
|
||||||
|
typeof currentQueueName === "string" &&
|
||||||
|
VISIT_KEYWORDS.some((keyword) => currentQueueName.toLowerCase().includes(keyword))
|
||||||
|
if (isVisitQueueOnSubmit) {
|
||||||
|
if (!values.visitDate) {
|
||||||
|
form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
||||||
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
|
||||||
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
|
||||||
|
|
@ -506,6 +538,15 @@ export function NewTicketDialog({
|
||||||
const sel = queues.find((q) => q.name === values.queueName)
|
const sel = queues.find((q) => q.name === values.queueName)
|
||||||
const selectedAssignee = form.getValues("assigneeId") ?? null
|
const selectedAssignee = form.getValues("assigneeId") ?? null
|
||||||
const requesterToSend = values.requesterId as Id<"users">
|
const requesterToSend = values.requesterId as Id<"users">
|
||||||
|
let visitDateTimestamp: number | undefined
|
||||||
|
if (isVisitQueueOnSubmit && values.visitDate) {
|
||||||
|
try {
|
||||||
|
const parsed = parseISO(values.visitDate)
|
||||||
|
visitDateTimestamp = parsed.getTime()
|
||||||
|
} catch {
|
||||||
|
visitDateTimestamp = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
const id = await create({
|
const id = await create({
|
||||||
actorId: convexUserId as Id<"users">,
|
actorId: convexUserId as Id<"users">,
|
||||||
tenantId: DEFAULT_TENANT_ID,
|
tenantId: DEFAULT_TENANT_ID,
|
||||||
|
|
@ -520,6 +561,7 @@ export function NewTicketDialog({
|
||||||
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
subcategoryId: values.subcategoryId as Id<"ticketSubcategories">,
|
||||||
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
|
||||||
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
|
||||||
|
visitDate: visitDateTimestamp,
|
||||||
})
|
})
|
||||||
const summaryFallback = values.summary?.trim() ?? ""
|
const summaryFallback = values.summary?.trim() ?? ""
|
||||||
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
|
||||||
|
|
@ -551,6 +593,7 @@ export function NewTicketDialog({
|
||||||
assigneeId: convexUserId ?? null,
|
assigneeId: convexUserId ?? null,
|
||||||
categoryId: "",
|
categoryId: "",
|
||||||
subcategoryId: "",
|
subcategoryId: "",
|
||||||
|
visitDate: null,
|
||||||
})
|
})
|
||||||
form.clearErrors()
|
form.clearErrors()
|
||||||
setSelectedFormKey("default")
|
setSelectedFormKey("default")
|
||||||
|
|
@ -919,6 +962,60 @@ export function NewTicketDialog({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
{isVisitQueue ? (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel className="flex items-center gap-1">
|
||||||
|
Data da visita <span className="text-destructive">*</span>
|
||||||
|
</FieldLabel>
|
||||||
|
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-left text-sm text-neutral-800 shadow-sm hover:bg-slate-50",
|
||||||
|
!visitDate && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{visitDate
|
||||||
|
? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
|
||||||
|
: "Selecione a data"}
|
||||||
|
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={visitDate ?? undefined}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (!date) {
|
||||||
|
form.setValue("visitDate", null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: form.formState.isSubmitted,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const iso = date.toISOString().slice(0, 10)
|
||||||
|
form.setValue("visitDate", iso, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: form.formState.isSubmitted,
|
||||||
|
})
|
||||||
|
setVisitDatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FieldError
|
||||||
|
errors={
|
||||||
|
form.formState.errors.visitDate
|
||||||
|
? [{ message: form.formState.errors.visitDate.message as string }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Responsável</FieldLabel>
|
<FieldLabel>Responsável</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
import { useMemo } from "react"
|
import { useCallback, useMemo } from "react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
||||||
|
import { useQuery } from "convex/react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
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 { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
import { getSlaDisplayStatus, getSlaDueDate, type SlaDisplayStatus } from "@/lib/sla-utils"
|
||||||
|
import { useAuth } from "@/lib/auth-client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { MonitorSmartphone } from "lucide-react"
|
||||||
|
import { api } from "@/convex/_generated/api"
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel"
|
||||||
|
import {
|
||||||
|
buildRustDeskUri,
|
||||||
|
isRustDeskAccess,
|
||||||
|
normalizeDeviceRemoteAccessList,
|
||||||
|
type DeviceRemoteAccessEntry,
|
||||||
|
} from "@/components/admin/devices/admin-devices-overview"
|
||||||
|
|
||||||
interface TicketDetailsPanelProps {
|
interface TicketDetailsPanelProps {
|
||||||
ticket: TicketWithDetails
|
ticket: TicketWithDetails
|
||||||
|
|
@ -81,6 +95,20 @@ type SummaryChipConfig = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
|
const { isStaff } = useAuth()
|
||||||
|
const machineId = ticket.machine?.id ?? null
|
||||||
|
const canLoadDevice = isStaff && Boolean(machineId)
|
||||||
|
|
||||||
|
const deviceRaw = useQuery(
|
||||||
|
api.devices.getById,
|
||||||
|
canLoadDevice
|
||||||
|
? ({
|
||||||
|
id: machineId as Id<"machines">,
|
||||||
|
includeMetadata: true,
|
||||||
|
} as const)
|
||||||
|
: "skip"
|
||||||
|
) as Record<string, unknown> | null | undefined
|
||||||
|
|
||||||
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 responseStatus = getSlaDisplayStatus(ticket, "response")
|
const responseStatus = getSlaDisplayStatus(ticket, "response")
|
||||||
|
|
@ -133,6 +161,17 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
return chips
|
return chips
|
||||||
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
|
}, [companyLabel, isAvulso, ticket.assignee, ticket.formTemplateLabel, ticket.priority, ticket.queue, ticket.status])
|
||||||
|
|
||||||
|
const remoteAccessEntries = useMemo<DeviceRemoteAccessEntry[]>(() => {
|
||||||
|
if (!deviceRaw) return []
|
||||||
|
const source = (deviceRaw as { remoteAccess?: unknown })?.remoteAccess
|
||||||
|
return normalizeDeviceRemoteAccessList(source)
|
||||||
|
}, [deviceRaw])
|
||||||
|
|
||||||
|
const primaryRemoteAccess = useMemo<DeviceRemoteAccessEntry | null>(
|
||||||
|
() => remoteAccessEntries.find((entry) => isRustDeskAccess(entry)) ?? null,
|
||||||
|
[remoteAccessEntries]
|
||||||
|
)
|
||||||
|
|
||||||
const agentTotals = useMemo(() => {
|
const agentTotals = useMemo(() => {
|
||||||
const totals = ticket.workSummary?.perAgentTotals ?? []
|
const totals = ticket.workSummary?.perAgentTotals ?? []
|
||||||
return totals
|
return totals
|
||||||
|
|
@ -146,6 +185,29 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
.sort((a, b) => b.totalWorkedMs - a.totalWorkedMs)
|
||||||
}, [ticket.workSummary?.perAgentTotals])
|
}, [ticket.workSummary?.perAgentTotals])
|
||||||
|
|
||||||
|
const handleRemoteConnect = useCallback(() => {
|
||||||
|
if (!primaryRemoteAccess) {
|
||||||
|
toast.error("Nenhum acesso remoto RustDesk cadastrado para esta máquina.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = buildRustDeskUri(primaryRemoteAccess)
|
||||||
|
if (!link) {
|
||||||
|
toast.error("Não foi possível montar o link do RustDesk (ID ou senha ausentes).")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
toast.error("A conexão direta só funciona no navegador.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.location.href = link
|
||||||
|
toast.success("Abrindo o RustDesk...")
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Não foi possível acionar o RustDesk neste dispositivo.")
|
||||||
|
}
|
||||||
|
}, [primaryRemoteAccess])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<CardHeader className="px-4 pb-3">
|
<CardHeader className="px-4 pb-3">
|
||||||
|
|
@ -173,6 +235,41 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{isStaff && machineId ? (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">Acesso remoto</h3>
|
||||||
|
{primaryRemoteAccess ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
<div className="space-y-1 text-sm text-neutral-700">
|
||||||
|
<p className="font-medium text-neutral-900">
|
||||||
|
Conecte-se remotamente à máquina vinculada a este ticket.
|
||||||
|
</p>
|
||||||
|
{ticket.machine?.hostname ? (
|
||||||
|
<p className="text-xs text-neutral-500">Host: {ticket.machine.hostname}</p>
|
||||||
|
) : null}
|
||||||
|
{primaryRemoteAccess.identifier ? (
|
||||||
|
<p className="text-xs text-neutral-500">ID RustDesk: {primaryRemoteAccess.identifier}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="inline-flex items-center gap-2 border-slate-300 bg-white text-sm font-semibold text-slate-800 shadow-sm hover:border-slate-400 hover:bg-slate-50"
|
||||||
|
onClick={handleRemoteConnect}
|
||||||
|
>
|
||||||
|
<MonitorSmartphone className="size-4 text-slate-700" />
|
||||||
|
Acessar remoto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Nenhum acesso remoto RustDesk está cadastrado para a máquina deste ticket.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">SLA & métricas</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { formatDistanceStrict } from "date-fns"
|
import { formatDistanceStrict } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
import { ptBR } from "date-fns/locale"
|
||||||
|
|
@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
|
||||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
|
import { getTicketStatusChipClass, getTicketStatusLabel } from "@/lib/ticket-status-style"
|
||||||
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
import { EmptyIndicator } from "@/components/ui/empty-indicator"
|
||||||
|
import { deriveServerOffset, toServerTimestamp } from "@/components/tickets/ticket-timer.utils"
|
||||||
|
|
||||||
type TicketsBoardProps = {
|
type TicketsBoardProps = {
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
|
|
@ -70,8 +71,24 @@ function formatQueueLabel(queue?: string | null) {
|
||||||
return { label: queue, title: queue }
|
return { label: queue, title: queue }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms?: number | null) {
|
||||||
|
if (!ms || ms <= 0) return "—"
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes.toString().padStart(2, "0")}m`
|
||||||
|
}
|
||||||
|
if (minutes > 0) {
|
||||||
|
return `${minutes}m ${seconds.toString().padStart(2, "0")}s`
|
||||||
|
}
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const serverOffsetRef = useRef<number>(0)
|
||||||
|
|
||||||
const ticketTimestamps = useMemo(() => {
|
const ticketTimestamps = useMemo(() => {
|
||||||
return tickets
|
return tickets
|
||||||
|
|
@ -97,6 +114,31 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
return () => window.clearTimeout(timeoutId)
|
return () => window.clearTimeout(timeoutId)
|
||||||
}, [ticketTimestamps, now])
|
}, [ticketTimestamps, now])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const candidates = tickets
|
||||||
|
.map((ticket) =>
|
||||||
|
typeof ticket.workSummary?.serverNow === "number" ? ticket.workSummary.serverNow : null
|
||||||
|
)
|
||||||
|
.filter((value): value is number => value !== null)
|
||||||
|
if (candidates.length === 0) return
|
||||||
|
const latestServerNow = candidates[candidates.length - 1]
|
||||||
|
serverOffsetRef.current = deriveServerOffset({
|
||||||
|
currentOffset: serverOffsetRef.current,
|
||||||
|
localNow: Date.now(),
|
||||||
|
serverNow: latestServerNow,
|
||||||
|
})
|
||||||
|
}, [tickets])
|
||||||
|
|
||||||
|
const getWorkedMs = (ticket: Ticket) => {
|
||||||
|
const base = ticket.workSummary?.totalWorkedMs ?? 0
|
||||||
|
const activeStart = ticket.workSummary?.activeSession?.startedAt
|
||||||
|
if (activeStart instanceof Date) {
|
||||||
|
const alignedNow = toServerTimestamp(now, serverOffsetRef.current)
|
||||||
|
return base + Math.max(0, alignedNow - activeStart.getTime())
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
if (!tickets.length) {
|
if (!tickets.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
|
<div className="rounded-3xl border border-slate-200 bg-white p-12 text-center shadow-sm">
|
||||||
|
|
@ -233,7 +275,7 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-center border-t border-slate-200 pt-4 text-sm text-neutral-600 text-center">
|
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 border-t border-slate-200 pt-4 text-sm text-neutral-600">
|
||||||
<span className="text-neutral-700">
|
<span className="text-neutral-700">
|
||||||
Categoria:{" "}
|
Categoria:{" "}
|
||||||
{ticket.category?.name ? (
|
{ticket.category?.name ? (
|
||||||
|
|
@ -246,6 +288,15 @@ export function TicketsBoard({ tickets, enteringIds }: TicketsBoardProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
Tempo:{" "}
|
||||||
|
<span className="font-semibold text-neutral-900">
|
||||||
|
{formatDuration(getWorkedMs(ticket))}
|
||||||
|
</span>
|
||||||
|
{ticket.workSummary?.activeSession ? (
|
||||||
|
<span className="ml-1 text-[11px] font-medium text-emerald-600">Em andamento</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,12 @@ function computeRange(period: AgendaPeriod, pivot: Date) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deriveScheduleWindow(ticket: Ticket) {
|
function deriveScheduleWindow(ticket: Ticket) {
|
||||||
|
if (isVisitTicket(ticket) && ticket.dueAt) {
|
||||||
|
const startAt = ticket.dueAt
|
||||||
|
const endAt = addMinutes(startAt, DEFAULT_EVENT_DURATION_MINUTES)
|
||||||
|
return { startAt, endAt }
|
||||||
|
}
|
||||||
|
|
||||||
const due = getSlaDueDate(ticket, "solution")
|
const due = getSlaDueDate(ticket, "solution")
|
||||||
if (!due) {
|
if (!due) {
|
||||||
return { startAt: null, endAt: null }
|
return { startAt: null, endAt: null }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue