From 714b19987946da47890e4940cbee2d722f41f069 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Mon, 27 Oct 2025 18:00:28 -0300 Subject: [PATCH] feat: export reports as xlsx and add machine inventory --- agents.md | 13 +- convex/machines.ts | 50 ++- convex/reports.ts | 190 ++++++++++ convex/schema.ts | 16 +- convex/tickets.ts | 352 +++++++++++++---- convex/users.ts | 38 +- .../migration.sql | 28 ++ prisma/schema.prisma | 5 + src/app/admin/users/page.tsx | 11 + src/app/api/admin/users/[id]/route.ts | 160 ++++++-- src/app/api/admin/users/route.ts | 57 +++ .../{backlog.csv => backlog.xlsx}/route.ts | 55 +-- .../reports/{csat.csv => csat.xlsx}/route.ts | 72 ++-- .../route.ts | 69 ++-- .../reports/machines-inventory.xlsx/route.ts | 266 +++++++++++++ .../reports/{sla.csv => sla.xlsx}/route.ts | 78 ++-- .../route.ts | 49 ++- src/app/api/tickets/[id]/export/pdf/route.ts | 2 +- src/app/reports/company/page.tsx | 24 ++ .../machines/admin-machines-overview.tsx | 77 +++- .../admin/users/admin-users-workspace.tsx | 149 +++++++- src/components/app-sidebar.tsx | 1 + src/components/chart-area-interactive.tsx | 4 +- src/components/portal/portal-ticket-form.tsx | 1 + src/components/reports/backlog-report.tsx | 4 +- src/components/reports/company-report.tsx | 355 ++++++++++++++++++ src/components/reports/csat-report.tsx | 4 +- src/components/reports/hours-report.tsx | 4 +- src/components/reports/sla-report.tsx | 4 +- src/components/tickets/ticket-timeline.tsx | 22 +- src/lib/mappers/ticket.ts | 30 ++ src/lib/schemas/ticket.ts | 11 + src/lib/xlsx.ts | 327 ++++++++++++++++ src/server/pdf/ticket-pdf-template.tsx | 21 ++ 34 files changed, 2304 insertions(+), 245 deletions(-) create mode 100644 prisma/migrations/20251027195301_add_user_manager_fields/migration.sql rename src/app/api/reports/{backlog.csv => backlog.xlsx}/route.ts (66%) rename src/app/api/reports/{csat.csv => csat.xlsx}/route.ts (55%) rename src/app/api/reports/{hours-by-client.csv => hours-by-client.xlsx}/route.ts (56%) create mode 100644 src/app/api/reports/machines-inventory.xlsx/route.ts rename src/app/api/reports/{sla.csv => sla.xlsx}/route.ts (55%) rename src/app/api/reports/{tickets-by-channel.csv => tickets-by-channel.xlsx}/route.ts (67%) create mode 100644 src/app/reports/company/page.tsx create mode 100644 src/components/reports/company-report.tsx create mode 100644 src/lib/xlsx.ts diff --git a/agents.md b/agents.md index 2d3c409..5bb4ec7 100644 --- a/agents.md +++ b/agents.md @@ -154,12 +154,13 @@ pnpm build - `POST /api/machines/register` - `POST /api/machines/heartbeat` - `POST /api/machines/inventory` -- **Relatórios CSV**: - - Backlog: `/api/reports/backlog.csv?range=7d|30d|90d[&companyId=...]` - - Canais: `/api/reports/tickets-by-channel.csv?...` - - CSAT: `/api/reports/csat.csv?...` - - SLA: `/api/reports/sla.csv?...` - - Horas: `/api/reports/hours-by-client.csv?...` +- **Relatórios XLSX**: + - Backlog: `/api/reports/backlog.xlsx?range=7d|30d|90d[&companyId=...]` + - Canais: `/api/reports/tickets-by-channel.xlsx?...` + - CSAT: `/api/reports/csat.xlsx?...` + - SLA: `/api/reports/sla.xlsx?...` + - Horas: `/api/reports/hours-by-client.xlsx?...` + - Inventário de máquinas: `/api/reports/machines-inventory.xlsx?[companyId=...]` - **Docs complementares**: - `docs/DEV.md` — guia diário atualizado. - `docs/STATUS-2025-10-16.md` — snapshot do estado atual e backlog. diff --git a/convex/machines.ts b/convex/machines.ts index 1ac3a8d..af2dd97 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -6,6 +6,7 @@ import { sha256 } from "@noble/hashes/sha256" import { randomBytes } from "@noble/hashes/utils" import type { Doc, Id } from "./_generated/dataModel" import type { MutationCtx } from "./_generated/server" +import { normalizeStatus } from "./tickets" const DEFAULT_TENANT_ID = "tenant-atlas" const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias @@ -27,7 +28,7 @@ function getTokenTtlMs(): number { return parsed } -function getOfflineThresholdMs(): number { +export function getOfflineThresholdMs(): number { const raw = process.env["MACHINE_OFFLINE_THRESHOLD_MS"] if (!raw) return DEFAULT_OFFLINE_THRESHOLD_MS const parsed = Number(raw) @@ -37,7 +38,7 @@ function getOfflineThresholdMs(): number { return parsed } -function getStaleThresholdMs(offlineMs: number): number { +export function getStaleThresholdMs(offlineMs: number): number { const raw = process.env["MACHINE_STALE_THRESHOLD_MS"] if (!raw) return offlineMs * 12 const parsed = Number(raw) @@ -1019,6 +1020,51 @@ export const listAlerts = query({ }, }) +export const listOpenTickets = query({ + args: { + machineId: v.id("machines"), + limit: v.optional(v.number()), + }, + handler: async (ctx, { machineId, limit }) => { + const machine = await ctx.db.get(machineId) + if (!machine) { + return [] + } + const takeLimit = Math.max(1, Math.min(limit ?? 10, 50)) + const candidates = await ctx.db + .query("tickets") + .withIndex("by_tenant_machine", (q) => q.eq("tenantId", machine.tenantId).eq("machineId", machineId)) + .order("desc") + .take(200) + + const openTickets = candidates + .filter((ticket) => normalizeStatus(ticket.status) !== "RESOLVED") + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, takeLimit) + + return openTickets.map((ticket) => ({ + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority ?? "MEDIUM", + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + machine: { + id: String(ticket.machineId ?? machineId), + hostname: + ((ticket.machineSnapshot as { hostname?: string } | undefined)?.hostname ?? machine.hostname ?? null), + }, + })) + }, +}) + export const updatePersona = mutation({ args: { machineId: v.id("machines"), diff --git a/convex/reports.ts b/convex/reports.ts index 428cd11..57b5454 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -4,6 +4,7 @@ import { ConvexError, v } from "convex/values"; import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; +import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; @@ -87,6 +88,35 @@ async function fetchQueues(ctx: QueryCtx, tenantId: string) { .collect(); } +function deriveMachineStatus(machine: Doc<"machines">, now: number) { + if (machine.isActive === false) { + return "deactivated"; + } + const manualStatus = (machine.status ?? "").toLowerCase(); + if (manualStatus === "maintenance" || manualStatus === "blocked") { + return manualStatus; + } + const offlineMs = getOfflineThresholdMs(); + const staleMs = getStaleThresholdMs(offlineMs); + if (machine.lastHeartbeatAt) { + const age = now - machine.lastHeartbeatAt; + if (age <= offlineMs) return "online"; + if (age <= staleMs) return "offline"; + return "stale"; + } + return (machine.status ?? "unknown") || "unknown"; +} + +function formatOsLabel(osName?: string | null, osVersion?: string | null) { + const name = osName?.trim(); + if (!name) return "Desconhecido"; + const version = osVersion?.trim(); + if (!version) return name; + const conciseVersion = version.split(" ")[0]; + if (!conciseVersion) return name; + return `${name} ${conciseVersion}`.trim(); +} + type CsatSurvey = { ticketId: Id<"tickets">; reference: number; @@ -707,3 +737,163 @@ export const hoursByClientInternal = query({ } }, }) + + +export const companyOverview = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + companyId: v.id("companies"), + range: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, companyId, range }) => { + const viewer = await requireStaff(ctx, viewerId, tenantId); + if (viewer.role === "MANAGER" && viewer.user.companyId && viewer.user.companyId !== companyId) { + throw new ConvexError("Gestores só podem consultar relatórios da própria empresa"); + } + + const company = await ctx.db.get(companyId); + if (!company || company.tenantId !== tenantId) { + throw new ConvexError("Empresa não encontrada"); + } + + const normalizedRange = (range ?? "30d").toLowerCase(); + const rangeDays = normalizedRange === "90d" ? 90 : normalizedRange === "7d" ? 7 : 30; + const now = Date.now(); + const startMs = now - rangeDays * ONE_DAY_MS; + + const tickets = await ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const machines = await ctx.db + .query("machines") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const users = await ctx.db + .query("users") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", tenantId).eq("companyId", companyId)) + .collect(); + + const statusCounts = {} as Record; + const priorityCounts = {} as Record; + const channelCounts = {} as Record; + const trendMap = new Map(); + const openTickets: Doc<"tickets">[] = []; + + tickets.forEach((ticket) => { + const normalizedStatus = normalizeStatus(ticket.status); + statusCounts[normalizedStatus] = (statusCounts[normalizedStatus] ?? 0) + 1; + + const priorityKey = (ticket.priority ?? "MEDIUM").toUpperCase(); + priorityCounts[priorityKey] = (priorityCounts[priorityKey] ?? 0) + 1; + + const channelKey = (ticket.channel ?? "MANUAL").toUpperCase(); + channelCounts[channelKey] = (channelCounts[channelKey] ?? 0) + 1; + + if (normalizedStatus !== "RESOLVED") { + openTickets.push(ticket); + } + + if (ticket.createdAt >= startMs) { + const key = formatDateKey(ticket.createdAt); + if (!trendMap.has(key)) { + trendMap.set(key, { opened: 0, resolved: 0 }); + } + trendMap.get(key)!.opened += 1; + } + + if (typeof ticket.resolvedAt === "number" && ticket.resolvedAt >= startMs) { + const key = formatDateKey(ticket.resolvedAt); + if (!trendMap.has(key)) { + trendMap.set(key, { opened: 0, resolved: 0 }); + } + trendMap.get(key)!.resolved += 1; + } + }); + + const machineStatusCounts: Record = {}; + const machineOsCounts: Record = {}; + const machineLookup = new Map>(); + + machines.forEach((machine) => { + machineLookup.set(String(machine._id), machine); + const status = deriveMachineStatus(machine, now); + machineStatusCounts[status] = (machineStatusCounts[status] ?? 0) + 1; + const osLabel = formatOsLabel(machine.osName ?? null, machine.osVersion ?? null); + machineOsCounts[osLabel] = (machineOsCounts[osLabel] ?? 0) + 1; + }); + + const roleCounts: Record = {}; + users.forEach((user) => { + const roleKey = (user.role ?? "COLLABORATOR").toUpperCase(); + roleCounts[roleKey] = (roleCounts[roleKey] ?? 0) + 1; + }); + + const trend = Array.from(trendMap.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, value]) => ({ date, opened: value.opened, resolved: value.resolved })); + + const machineOsDistribution = Object.entries(machineOsCounts) + .map(([label, value]) => ({ label, value })) + .sort((a, b) => b.value - a.value); + + const openTicketSummaries = openTickets + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, 20) + .map((ticket) => { + const machineSnapshot = ticket.machineSnapshot as { hostname?: string } | undefined; + const machine = ticket.machineId ? machineLookup.get(String(ticket.machineId)) : null; + return { + id: ticket._id, + reference: ticket.reference, + subject: ticket.subject, + status: normalizeStatus(ticket.status), + priority: ticket.priority, + updatedAt: ticket.updatedAt, + createdAt: ticket.createdAt, + machine: ticket.machineId + ? { + id: ticket.machineId, + hostname: machine?.hostname ?? machineSnapshot?.hostname ?? null, + } + : null, + assignee: ticket.assigneeSnapshot + ? { + name: (ticket.assigneeSnapshot as { name?: string })?.name ?? null, + email: (ticket.assigneeSnapshot as { email?: string })?.email ?? null, + } + : null, + }; + }); + + return { + company: { + id: company._id, + name: company.name, + isAvulso: company.isAvulso ?? false, + }, + rangeDays, + generatedAt: now, + tickets: { + total: tickets.length, + byStatus: statusCounts, + byPriority: priorityCounts, + byChannel: channelCounts, + trend, + open: openTicketSummaries, + }, + machines: { + total: machines.length, + byStatus: machineStatusCounts, + byOs: machineOsDistribution, + }, + users: { + total: users.length, + byRole: roleCounts, + }, + }; + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 2c7fedb..353fdfc 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,6 +7,8 @@ export default defineSchema({ name: v.string(), email: v.string(), role: v.optional(v.string()), + jobTitle: v.optional(v.string()), + managerId: v.optional(v.id("users")), avatarUrl: v.optional(v.string()), teams: v.optional(v.array(v.string())), companyId: v.optional(v.id("companies")), @@ -14,7 +16,8 @@ export default defineSchema({ .index("by_tenant_email", ["tenantId", "email"]) .index("by_tenant_role", ["tenantId", "role"]) .index("by_tenant", ["tenantId"]) - .index("by_tenant_company", ["tenantId", "companyId"]), + .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_manager", ["tenantId", "managerId"]), companies: defineTable({ tenantId: v.string(), @@ -135,6 +138,16 @@ export default defineSchema({ isAvulso: v.optional(v.boolean()), }) ), + machineId: v.optional(v.id("machines")), + machineSnapshot: v.optional( + v.object({ + hostname: v.optional(v.string()), + persona: v.optional(v.string()), + assignedUserName: v.optional(v.string()), + assignedUserEmail: v.optional(v.string()), + status: v.optional(v.string()), + }) + ), working: v.optional(v.boolean()), slaPolicyId: v.optional(v.id("slaPolicies")), dueAt: v.optional(v.number()), // ms since epoch @@ -167,6 +180,7 @@ export default defineSchema({ .index("by_tenant_reference", ["tenantId", "reference"]) .index("by_tenant_requester", ["tenantId", "requesterId"]) .index("by_tenant_company", ["tenantId", "companyId"]) + .index("by_tenant_machine", ["tenantId", "machineId"]) .index("by_tenant", ["tenantId"]), ticketComments: defineTable({ diff --git a/convex/tickets.ts b/convex/tickets.ts index 080ed25..12cc446 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -2,7 +2,8 @@ import { mutation, query } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { ConvexError, v } from "convex/values"; -import { Id, type Doc } from "./_generated/dataModel"; +import { Id, type Doc, type DataModel } from "./_generated/dataModel"; +import type { NamedTableInfo, Query as ConvexQuery } from "convex/server"; import { requireAdmin, requireStaff, requireUser } from "./rbac"; @@ -176,7 +177,7 @@ async function normalizeTicketMentions( return output } -function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { +export function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; return normalized ?? "PENDING"; @@ -651,6 +652,39 @@ function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) { }, {}); } +const DEFAULT_TICKETS_LIST_LIMIT = 250; +const MIN_TICKETS_LIST_LIMIT = 25; +const MAX_TICKETS_LIST_LIMIT = 600; +const MAX_FETCH_LIMIT = 1000; +const FETCH_MULTIPLIER_NO_SEARCH = 3; +const FETCH_MULTIPLIER_WITH_SEARCH = 5; + +type TicketsTableInfo = NamedTableInfo; +type TicketsQueryBuilder = ConvexQuery; + +function clampTicketLimit(limit: number) { + if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; + return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit))); +} + +function computeFetchLimit(limit: number, hasSearch: boolean) { + const multiplier = hasSearch ? FETCH_MULTIPLIER_WITH_SEARCH : FETCH_MULTIPLIER_NO_SEARCH; + const target = limit * multiplier; + return Math.max(limit, Math.min(MAX_FETCH_LIMIT, target)); +} + +function dedupeTicketsById(tickets: Doc<"tickets">[]) { + const seen = new Set(); + const result: Doc<"tickets">[] = []; + for (const ticket of tickets) { + const key = String(ticket._id); + if (seen.has(key)) continue; + seen.add(key); + result.push(ticket); + } + return result; +} + export const list = query({ args: { viewerId: v.optional(v.id("users")), @@ -666,85 +700,138 @@ export const list = query({ }, handler: async (ctx, args) => { if (!args.viewerId) { - return [] + return []; + } + const viewerId = args.viewerId as Id<"users">; + const { user, role } = await requireUser(ctx, viewerId, args.tenantId); + if (role === "MANAGER" && !user.companyId) { + throw new ConvexError("Gestor não possui empresa vinculada"); } - const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId) - // Choose best index based on provided args for efficiency - let base: Doc<"tickets">[] = []; - if (role === "MANAGER") { - if (!user.companyId) { - throw new ConvexError("Gestor não possui empresa vinculada") + const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; + const normalizedPriorityFilter = args.priority ? args.priority.toUpperCase() : null; + const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; + const searchTerm = args.search?.trim().toLowerCase() ?? null; + + const requestedLimitRaw = typeof args.limit === "number" ? args.limit : DEFAULT_TICKETS_LIST_LIMIT; + const requestedLimit = clampTicketLimit(requestedLimitRaw); + const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm)); + + const applyQueryFilters = (query: TicketsQueryBuilder) => { + let working = query; + if (normalizedStatusFilter) { + working = working.filter((q) => q.eq(q.field("status"), normalizedStatusFilter)); } - // Managers are scoped to company; allow secondary narrowing by requester/assignee - base = await ctx.db - .query("tickets") - .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) - .collect(); + if (normalizedPriorityFilter) { + working = working.filter((q) => q.eq(q.field("priority"), normalizedPriorityFilter)); + } + if (normalizedChannelFilter) { + working = working.filter((q) => q.eq(q.field("channel"), normalizedChannelFilter)); + } + if (args.queueId) { + working = working.filter((q) => q.eq(q.field("queueId"), args.queueId!)); + } + if (args.assigneeId) { + working = working.filter((q) => q.eq(q.field("assigneeId"), args.assigneeId!)); + } + if (args.requesterId) { + working = working.filter((q) => q.eq(q.field("requesterId"), args.requesterId!)); + } + return working; + }; + + let base: Doc<"tickets">[] = []; + + if (role === "MANAGER") { + const baseQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) + ); + base = await baseQuery.order("desc").take(fetchLimit); } else if (args.assigneeId) { - base = await ctx.db - .query("tickets") - .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)) - .collect(); + const baseQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)) + ); + base = await baseQuery.order("desc").take(fetchLimit); } else if (args.requesterId) { - base = await ctx.db - .query("tickets") - .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)) - .collect(); + const baseQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)) + ); + base = await baseQuery.order("desc").take(fetchLimit); } else if (args.queueId) { - base = await ctx.db - .query("tickets") - .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)) - .collect(); + const baseQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!)) + ); + base = await baseQuery.order("desc").take(fetchLimit); + } else if (normalizedStatusFilter) { + const baseQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter)) + ); + base = await baseQuery.order("desc").take(fetchLimit); } else if (role === "COLLABORATOR") { - // Colaborador: exibir apenas tickets onde ele é o solicitante - // Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer - const all = await ctx.db - .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) - .collect() - const viewerEmail = user.email.trim().toLowerCase() - base = all.filter((t) => { - if (t.requesterId === args.viewerId) return true - const rs = t.requesterSnapshot as { email?: string } | undefined - const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null - return Boolean(email && email === viewerEmail) - }) + const viewerEmail = user.email.trim().toLowerCase(); + const directQuery = applyQueryFilters( + ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId)) + ); + const directTickets = await directQuery.order("desc").take(fetchLimit); + + let combined = directTickets; + if (directTickets.length < fetchLimit) { + const fallbackQuery = applyQueryFilters( + ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) + ); + const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit); + const fallbackMatches = fallbackRaw.filter((ticket) => { + const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email; + if (typeof snapshotEmail !== "string") return false; + return snapshotEmail.trim().toLowerCase() === viewerEmail; + }); + combined = dedupeTicketsById([...directTickets, ...fallbackMatches]); + } + base = combined.slice(0, fetchLimit); } else { - base = await ctx.db - .query("tickets") - .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) - .collect(); + const baseQuery = applyQueryFilters( + ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) + ); + base = await baseQuery.order("desc").take(fetchLimit); } + let filtered = base; if (role === "MANAGER") { - if (!user.companyId) { - throw new ConvexError("Gestor não possui empresa vinculada") - } - filtered = filtered.filter((t) => t.companyId === user.companyId) + filtered = filtered.filter((t) => t.companyId === user.companyId); } - const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; - - if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); - if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel); + if (normalizedPriorityFilter) filtered = filtered.filter((t) => t.priority === normalizedPriorityFilter); + if (normalizedChannelFilter) filtered = filtered.filter((t) => t.channel === normalizedChannelFilter); if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); if (normalizedStatusFilter) { filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); } - if (args.search) { - const term = args.search.toLowerCase(); + if (searchTerm) { filtered = filtered.filter( (t) => - t.subject.toLowerCase().includes(term) || - t.summary?.toLowerCase().includes(term) || - `#${t.reference}`.toLowerCase().includes(term) + t.subject.toLowerCase().includes(searchTerm) || + t.summary?.toLowerCase().includes(searchTerm) || + `#${t.reference}`.toLowerCase().includes(searchTerm) ); } - const limited = args.limit ? filtered.slice(0, args.limit) : filtered; + + const limited = filtered.slice(0, requestedLimit); const categoryCache = new Map | null>(); const subcategoryCache = new Map | null>(); + const machineCache = new Map | null>(); // hydrate requester and assignee const result = await Promise.all( limited.map(async (t) => { @@ -774,6 +861,49 @@ export const list = query({ subcategorySummary = { id: subcategory._id, name: subcategory.name }; } } + const machineSnapshot = t.machineSnapshot as + | { + hostname?: string + persona?: string + assignedUserName?: string + assignedUserEmail?: string + status?: string + } + | undefined; + let machineSummary: + | { + id: Id<"machines"> | null + hostname: string | null + persona: string | null + assignedUserName: string | null + assignedUserEmail: string | null + status: string | null + } + | null = null; + if (t.machineId) { + const cacheKey = String(t.machineId); + if (!machineCache.has(cacheKey)) { + machineCache.set(cacheKey, (await ctx.db.get(t.machineId)) as Doc<"machines"> | null); + } + const machineDoc = machineCache.get(cacheKey); + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, + }; + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } const serverNow = Date.now() return { id: t._id, @@ -819,6 +949,7 @@ export const list = query({ metrics: null, category: categorySummary, subcategory: subcategorySummary, + machine: machineSummary, workSummary: { totalWorkedMs: t.totalWorkedMs ?? 0, internalWorkedMs: t.internalWorkedMs ?? 0, @@ -867,6 +998,45 @@ export const getById = query({ const assignee = t.assigneeId ? ((await ctx.db.get(t.assigneeId)) as Doc<"users"> | null) : null; const queue = t.queueId ? ((await ctx.db.get(t.queueId)) as Doc<"queues"> | null) : null; const company = t.companyId ? ((await ctx.db.get(t.companyId)) as Doc<"companies"> | null) : null; + const machineSnapshot = t.machineSnapshot as + | { + hostname?: string + persona?: string + assignedUserName?: string + assignedUserEmail?: string + status?: string + } + | undefined; + let machineSummary: + | { + id: Id<"machines"> | null + hostname: string | null + persona: string | null + assignedUserName: string | null + assignedUserEmail: string | null + status: string | null + } + | null = null; + if (t.machineId) { + const machineDoc = (await ctx.db.get(t.machineId)) as Doc<"machines"> | null; + machineSummary = { + id: t.machineId, + hostname: machineDoc?.hostname ?? machineSnapshot?.hostname ?? null, + persona: machineDoc?.persona ?? machineSnapshot?.persona ?? null, + assignedUserName: machineDoc?.assignedUserName ?? machineSnapshot?.assignedUserName ?? null, + assignedUserEmail: machineDoc?.assignedUserEmail ?? machineSnapshot?.assignedUserEmail ?? null, + status: machineDoc?.status ?? machineSnapshot?.status ?? null, + }; + } else if (machineSnapshot) { + machineSummary = { + id: null, + hostname: machineSnapshot.hostname ?? null, + persona: machineSnapshot.persona ?? null, + assignedUserName: machineSnapshot.assignedUserName ?? null, + assignedUserEmail: machineSnapshot.assignedUserEmail ?? null, + status: machineSnapshot.status ?? null, + }; + } const queueName = normalizeQueueName(queue); const category = t.categoryId ? await ctx.db.get(t.categoryId) : null; const subcategory = t.subcategoryId ? await ctx.db.get(t.subcategoryId) : null; @@ -1011,6 +1181,7 @@ export const getById = query({ tags: t.tags ?? [], lastTimelineEntry: null, metrics: null, + machine: machineSummary, category: category ? { id: category._id, @@ -1082,6 +1253,7 @@ export const create = mutation({ assigneeId: v.optional(v.id("users")), categoryId: v.id("ticketCategories"), subcategoryId: v.id("ticketSubcategories"), + machineId: v.optional(v.id("machines")), customFields: v.optional( v.array( v.object({ @@ -1147,6 +1319,15 @@ export const create = mutation({ } } + let machineDoc: Doc<"machines"> | null = null + if (args.machineId) { + const machine = (await ctx.db.get(args.machineId)) as Doc<"machines"> | null + if (!machine || machine.tenantId !== args.tenantId) { + throw new ConvexError("Máquina inválida para este chamado") + } + machineDoc = machine + } + const normalizedCustomFields = await normalizeCustomFieldValues(ctx, args.tenantId, args.customFields ?? undefined); // compute next reference (simple monotonic counter per tenant) const existing = await ctx.db @@ -1156,14 +1337,20 @@ export const create = mutation({ .take(1); const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000; const now = Date.now(); - const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING"; + const initialStatus: TicketStatusNormalized = "PENDING"; const requesterSnapshot = { name: requester.name, email: requester.email, avatarUrl: requester.avatarUrl ?? undefined, teams: requester.teams ?? undefined, } - const companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null + let companyDoc = requester.companyId ? (await ctx.db.get(requester.companyId)) : null + if (!companyDoc && machineDoc?.companyId) { + const candidateCompany = await ctx.db.get(machineDoc.companyId) + if (candidateCompany && candidateCompany.tenantId === args.tenantId) { + companyDoc = candidateCompany as Doc<"companies"> + } + } const companySnapshot = companyDoc ? { name: companyDoc.name, slug: companyDoc.slug, isAvulso: companyDoc.isAvulso ?? undefined } : undefined @@ -1205,8 +1392,18 @@ export const create = mutation({ requesterSnapshot, assigneeId: initialAssigneeId, assigneeSnapshot, - companyId: requester.companyId ?? undefined, + companyId: companyDoc?._id ?? requester.companyId ?? undefined, companySnapshot, + machineId: machineDoc?._id ?? undefined, + machineSnapshot: machineDoc + ? { + hostname: machineDoc.hostname ?? undefined, + persona: machineDoc.persona ?? undefined, + assignedUserName: machineDoc.assignedUserName ?? undefined, + assignedUserEmail: machineDoc.assignedUserEmail ?? undefined, + status: machineDoc.status ?? undefined, + } + : undefined, working: false, activeSessionId: undefined, totalWorkedMs: 0, @@ -1492,6 +1689,9 @@ export const updateStatus = mutation({ const ticketDoc = ticket as Doc<"tickets"> await requireTicketStaff(ctx, actorId, ticketDoc) const normalizedStatus = normalizeStatus(status) + if (normalizedStatus === "AWAITING_ATTENDANCE" && !ticketDoc.activeSessionId) { + throw new ConvexError("Inicie o atendimento antes de marcar o ticket como em andamento.") + } const now = Date.now(); await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now }); await ctx.db.insert("ticketEvents", { @@ -2006,6 +2206,26 @@ export const pauseWork = mutation({ } if (!ticketDoc.activeSessionId) { + const normalizedStatus = normalizeStatus(ticketDoc.status) + if (normalizedStatus === "AWAITING_ATTENDANCE") { + const now = Date.now() + await ctx.db.patch(ticketId, { + status: "PAUSED", + working: false, + updatedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "STATUS_CHANGED", + payload: { + to: "PAUSED", + toLabel: STATUS_LABELS.PAUSED, + actorId, + }, + createdAt: now, + }) + return { status: "paused", durationMs: 0, pauseReason: reason, pauseNote: note ?? "", serverNow: now } + } return { status: "already_paused" } } @@ -2278,15 +2498,21 @@ export const playNext = mutation({ const chosen = candidates[0]; const now = Date.now(); const currentStatus = normalizeStatus(chosen.status); - const nextStatus: TicketStatusNormalized = - currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus; + const nextStatus: TicketStatusNormalized = currentStatus; const assigneeSnapshot = { name: agent.name, email: agent.email, avatarUrl: agent.avatarUrl ?? undefined, teams: agent.teams ?? undefined, } - await ctx.db.patch(chosen._id, { assigneeId: agentId, assigneeSnapshot, status: nextStatus, updatedAt: now }); + await ctx.db.patch(chosen._id, { + assigneeId: agentId, + assigneeSnapshot, + status: nextStatus, + working: false, + activeSessionId: undefined, + updatedAt: now, + }); await ctx.db.insert("ticketEvents", { ticketId: chosen._id, type: "ASSIGNEE_CHANGED", diff --git a/convex/users.ts b/convex/users.ts index 9edf113..0c047ff 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -15,6 +15,8 @@ export const ensureUser = mutation({ role: v.optional(v.string()), teams: v.optional(v.array(v.string())), companyId: v.optional(v.id("companies")), + jobTitle: v.optional(v.string()), + managerId: v.optional(v.id("users")), }, handler: async (ctx, args) => { const existing = await ctx.db @@ -23,23 +25,38 @@ export const ensureUser = mutation({ .first(); const reconcile = async (record: typeof existing) => { if (!record) return null; + const hasJobTitleArg = Object.prototype.hasOwnProperty.call(args, "jobTitle"); + const hasManagerArg = Object.prototype.hasOwnProperty.call(args, "managerId"); + const jobTitleChanged = hasJobTitleArg ? (record.jobTitle ?? null) !== (args.jobTitle ?? null) : false; + const managerChanged = hasManagerArg + ? String(record.managerId ?? "") !== String(args.managerId ?? "") + : false; const shouldPatch = record.tenantId !== args.tenantId || (args.role && record.role !== args.role) || (args.avatarUrl && record.avatarUrl !== args.avatarUrl) || record.name !== args.name || (args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? [])) || - (args.companyId && record.companyId !== args.companyId); + (args.companyId && record.companyId !== args.companyId) || + jobTitleChanged || + managerChanged; if (shouldPatch) { - await ctx.db.patch(record._id, { + const patch: Record = { tenantId: args.tenantId, role: args.role ?? record.role, avatarUrl: args.avatarUrl ?? record.avatarUrl, name: args.name, teams: args.teams ?? record.teams, companyId: args.companyId ?? record.companyId, - }); + }; + if (hasJobTitleArg) { + patch.jobTitle = args.jobTitle ?? undefined; + } + if (hasManagerArg) { + patch.managerId = args.managerId ?? undefined; + } + await ctx.db.patch(record._id, patch); const updated = await ctx.db.get(record._id); if (updated) { return updated; @@ -70,6 +87,8 @@ export const ensureUser = mutation({ role: args.role ?? "AGENT", teams: args.teams ?? [], companyId: args.companyId, + jobTitle: args.jobTitle, + managerId: args.managerId, }); return await ctx.db.get(id); }, @@ -151,6 +170,8 @@ export const listCustomers = query({ companyName: company?.name ?? null, companyIsAvulso: Boolean(company?.isAvulso), avatarUrl: user.avatarUrl ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId ? String(user.managerId) : null, } }) .sort((a, b) => a.name.localeCompare(b.name ?? "", "pt-BR")) @@ -239,6 +260,17 @@ export const deleteUser = mutation({ } } + // Limpa vínculo de subordinados + const directReports = await ctx.db + .query("users") + .withIndex("by_tenant_manager", (q) => q.eq("tenantId", user.tenantId).eq("managerId", userId)) + .collect(); + await Promise.all( + directReports.map(async (report) => { + await ctx.db.patch(report._id, { managerId: undefined }); + }) + ); + await ctx.db.delete(userId); return { status: "deleted" }; }, diff --git a/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql b/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql new file mode 100644 index 0000000..831ea5c --- /dev/null +++ b/prisma/migrations/20251027195301_add_user_manager_fields/migration.sql @@ -0,0 +1,28 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" TEXT NOT NULL, + "jobTitle" TEXT, + "managerId" TEXT, + "timezone" TEXT NOT NULL DEFAULT 'America/Sao_Paulo', + "avatarUrl" TEXT, + "companyId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "User_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_User" ("avatarUrl", "companyId", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt") SELECT "avatarUrl", "companyId", "createdAt", "email", "id", "name", "role", "tenantId", "timezone", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +CREATE INDEX "User_tenantId_role_idx" ON "User"("tenantId", "role"); +CREATE INDEX "User_tenantId_companyId_idx" ON "User"("tenantId", "companyId"); +CREATE INDEX "User_tenantId_managerId_idx" ON "User"("tenantId", "managerId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfefb44..3d144e1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,8 @@ model User { name String email String @unique role UserRole + jobTitle String? + managerId String? timezone String @default("America/Sao_Paulo") avatarUrl String? companyId String? @@ -140,10 +142,13 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + manager User? @relation("UserManager", fields: [managerId], references: [id]) + reports User[] @relation("UserManager") company Company? @relation(fields: [companyId], references: [id]) @@index([tenantId, role]) @@index([tenantId, companyId]) + @@index([tenantId, managerId]) } model Queue { diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index 897384a..5065ca4 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -25,6 +25,13 @@ export default async function AdminUsersPage() { name: true, }, }, + manager: { + select: { + id: true, + name: true, + email: true, + }, + }, }, orderBy: { createdAt: "desc" }, }) @@ -71,6 +78,10 @@ export default async function AdminUsersPage() { role: user.role === "MANAGER" ? "MANAGER" : "COLLABORATOR", companyId: user.companyId ?? null, companyName: user.company?.name ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId ?? null, + managerName: user.manager?.name ?? null, + managerEmail: user.manager?.email ?? null, tenantId: user.tenantId, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index af112b6..ecb8ff3 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server" import type { Id } from "@/convex/_generated/dataModel" -import type { UserRole } from "@prisma/client" +import type { Prisma, UserRole } from "@prisma/client" import { api } from "@/convex/_generated/api" import { ConvexHttpClient } from "convex/browser" import { prisma } from "@/lib/prisma" @@ -57,6 +57,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string select: { companyId: true, company: { select: { name: true } }, + jobTitle: true, + managerId: true, + manager: { select: { id: true, name: true, email: true } }, }, }) @@ -72,6 +75,10 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string companyId: domain?.companyId ?? null, companyName: domain?.company?.name ?? null, machinePersona: user.machinePersona ?? null, + jobTitle: domain?.jobTitle ?? null, + managerId: domain?.managerId ?? null, + managerName: domain?.manager?.name ?? null, + managerEmail: domain?.manager?.email ?? null, }, }) } @@ -90,6 +97,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id role?: RoleOption tenantId?: string companyId?: string | null + jobTitle?: string | null + managerId?: string | null } | null if (!payload || typeof payload !== "object") { @@ -106,6 +115,39 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const nextRole = normalizeRole(payload.role ?? user.role) const nextTenant = (payload.tenantId ?? user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID const companyId = payload.companyId ? payload.companyId : null + const hasJobTitleField = Object.prototype.hasOwnProperty.call(payload, "jobTitle") + let jobTitle: string | null | undefined + if (hasJobTitleField) { + if (typeof payload.jobTitle === "string") { + const trimmed = payload.jobTitle.trim() + jobTitle = trimmed.length > 0 ? trimmed : null + } else { + jobTitle = null + } + } + const hasManagerField = Object.prototype.hasOwnProperty.call(payload, "managerId") + let managerIdValue: string | null | undefined + if (hasManagerField) { + if (typeof payload.managerId === "string") { + const trimmed = payload.managerId.trim() + managerIdValue = trimmed.length > 0 ? trimmed : null + } else { + managerIdValue = null + } + } + let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null + if (managerIdValue && managerIdValue !== null) { + managerRecord = await prisma.user.findUnique({ + where: { id: managerIdValue }, + select: { id: true, email: true, tenantId: true, name: true }, + }) + if (!managerRecord) { + return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 }) + } + if (managerRecord.tenantId !== nextTenant) { + return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 }) + } + } if (!nextEmail || !nextEmail.includes("@")) { return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) @@ -159,33 +201,58 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id return NextResponse.json({ error: "Empresa não encontrada" }, { status: 400 }) } + if (domainUser && managerRecord && managerRecord.id === domainUser.id) { + return NextResponse.json({ error: "Um usuário não pode ser gestor de si mesmo." }, { status: 400 }) + } + if (domainUser) { + const updateData: Prisma.UserUncheckedUpdateInput = { + email: nextEmail, + name: nextName || domainUser.name, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + updateData.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + updateData.managerId = managerRecord?.id ?? null + } await prisma.user.update({ where: { id: domainUser.id }, - data: { - email: nextEmail, - name: nextName || domainUser.name, - role: mapToUserRole(nextRole), - tenantId: nextTenant, - companyId: companyId ?? null, - }, + data: updateData, }) } else { + const upsertUpdate: Prisma.UserUncheckedUpdateInput = { + name: nextName || nextEmail, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + upsertUpdate.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + upsertUpdate.managerId = managerRecord?.id ?? null + } + const upsertCreate: Prisma.UserUncheckedCreateInput = { + email: nextEmail, + name: nextName || nextEmail, + role: mapToUserRole(nextRole), + tenantId: nextTenant, + companyId: companyId ?? null, + } + if (hasJobTitleField) { + upsertCreate.jobTitle = jobTitle ?? null + } + if (hasManagerField) { + upsertCreate.managerId = managerRecord?.id ?? null + } await prisma.user.upsert({ where: { email: nextEmail }, - update: { - name: nextName || nextEmail, - role: mapToUserRole(nextRole), - tenantId: nextTenant, - companyId: companyId ?? null, - }, - create: { - email: nextEmail, - name: nextName || nextEmail, - role: mapToUserRole(nextRole), - tenantId: nextTenant, - companyId: companyId ?? null, - }, + update: upsertUpdate, + create: upsertCreate, }) } @@ -193,19 +260,60 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id if (convexUrl) { try { const convex = new ConvexHttpClient(convexUrl) - await convex.mutation(api.users.ensureUser, { + let managerConvexId: Id<"users"> | undefined + if (hasManagerField && managerRecord?.email) { + try { + const managerUser = await convex.query(api.users.findByEmail, { + tenantId: nextTenant, + email: managerRecord.email, + }) + if (managerUser?._id) { + managerConvexId = managerUser._id as Id<"users"> + } + } catch (error) { + console.warn("Falha ao localizar gestor no Convex", error) + } + } + const ensurePayload: { + tenantId: string + email: string + name: string + avatarUrl?: string + role: string + companyId?: Id<"companies"> + jobTitle?: string | undefined + managerId?: Id<"users"> + } = { tenantId: nextTenant, email: nextEmail, name: nextName || nextEmail, avatarUrl: updated.avatarUrl ?? undefined, role: nextRole.toUpperCase(), - companyId: companyId ? (companyId as Id<"companies">) : undefined, - }) + } + if (companyId) { + ensurePayload.companyId = companyId as Id<"companies"> + } + if (hasJobTitleField) { + ensurePayload.jobTitle = jobTitle ?? undefined + } + if (hasManagerField) { + ensurePayload.managerId = managerConvexId + } + await convex.mutation(api.users.ensureUser, ensurePayload) } catch (error) { console.warn("Falha ao sincronizar usuário no Convex", error) } } + const updatedDomain = await prisma.user.findUnique({ + where: { email: nextEmail }, + select: { + jobTitle: true, + managerId: true, + manager: { select: { name: true, email: true } }, + }, + }) + return NextResponse.json({ user: { id: updated.id, @@ -218,6 +326,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id companyId, companyName: companyData?.name ?? null, machinePersona: updated.machinePersona ?? null, + jobTitle: updatedDomain?.jobTitle ?? null, + managerId: updatedDomain?.managerId ?? null, + managerName: updatedDomain?.manager?.name ?? null, + managerEmail: updatedDomain?.manager?.email ?? null, }, }) } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 040c74a..48c63da 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server" import { hashPassword } from "better-auth/crypto" import { ConvexHttpClient } from "convex/browser" import type { UserRole } from "@prisma/client" +import type { Id } from "@/convex/_generated/dataModel" import { prisma } from "@/lib/prisma" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -52,6 +53,13 @@ export async function GET() { name: true, }, }, + manager: { + select: { + id: true, + name: true, + email: true, + }, + }, }, orderBy: { createdAt: "desc" }, }) @@ -102,6 +110,10 @@ export async function GET() { role: normalizeRole(user.role), companyId: user.companyId, companyName: user.company?.name ?? null, + jobTitle: user.jobTitle ?? null, + managerId: user.managerId, + managerName: user.manager?.name ?? null, + managerEmail: user.manager?.email ?? null, tenantId: user.tenantId, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt.toISOString(), @@ -124,6 +136,8 @@ export async function POST(request: Request) { email?: string role?: string tenantId?: string + jobTitle?: string | null + managerId?: string | null } | null if (!body || typeof body !== "object") { @@ -141,6 +155,25 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 }) } + const rawJobTitle = typeof body.jobTitle === "string" ? body.jobTitle.trim() : null + const jobTitle = rawJobTitle ? rawJobTitle : null + const rawManagerId = typeof body.managerId === "string" ? body.managerId.trim() : "" + const managerId = rawManagerId.length > 0 ? rawManagerId : null + + let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null + if (managerId) { + managerRecord = await prisma.user.findUnique({ + where: { id: managerId }, + select: { id: true, email: true, tenantId: true, name: true }, + }) + if (!managerRecord) { + return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 }) + } + if (managerRecord.tenantId !== tenantId) { + return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 }) + } + } + const normalizedRole = normalizeRole(body.role) const authRole = normalizedRole.toLowerCase() const userRole = normalizedRole as UserRole @@ -176,12 +209,16 @@ export async function POST(request: Request) { name, role: userRole, tenantId, + jobTitle, + managerId: managerRecord?.id ?? null, }, create: { name, email, role: userRole, tenantId, + jobTitle, + managerId: managerRecord?.id ?? null, }, }) @@ -192,12 +229,28 @@ export async function POST(request: Request) { if (convexUrl) { try { const convex = new ConvexHttpClient(convexUrl) + let managerConvexId: Id<"users"> | undefined + if (managerRecord?.email) { + try { + const convexManager = await convex.query(api.users.findByEmail, { + tenantId, + email: managerRecord.email, + }) + if (convexManager?._id) { + managerConvexId = convexManager._id as Id<"users"> + } + } catch (error) { + console.warn("[admin/users] Falha ao localizar gestor no Convex", error) + } + } await convex.mutation(api.users.ensureUser, { tenantId, email, name, avatarUrl: authUser.avatarUrl ?? undefined, role: userRole, + jobTitle: jobTitle ?? undefined, + managerId: managerConvexId, }) } catch (error) { console.error("[admin/users] ensureUser failed", error) @@ -213,6 +266,10 @@ export async function POST(request: Request) { role: authRole, tenantId: domainUser.tenantId, createdAt: domainUser.createdAt.toISOString(), + jobTitle, + managerId: managerRecord?.id ?? null, + managerName: managerRecord?.name ?? null, + managerEmail: managerRecord?.email ?? null, }, temporaryPassword, }) diff --git a/src/app/api/reports/backlog.csv/route.ts b/src/app/api/reports/backlog.xlsx/route.ts similarity index 66% rename from src/app/api/reports/backlog.csv/route.ts rename to src/app/api/reports/backlog.xlsx/route.ts index 8b35d23..3b5d98e 100644 --- a/src/app/api/reports/backlog.csv/route.ts +++ b/src/app/api/reports/backlog.xlsx/route.ts @@ -6,7 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { rowsToCsv } from "@/lib/csv" +import { buildXlsxWorkbook } from "@/lib/xlsx" export const runtime = "nodejs" @@ -35,7 +35,7 @@ export async function GET(request: Request) { }) viewerId = ensuredUser?._id ?? null } catch (error) { - console.error("Failed to synchronize user with Convex for backlog CSV", error) + console.error("Failed to synchronize user with Convex for backlog export", error) return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) } @@ -54,14 +54,17 @@ export async function GET(request: Request) { companyId: companyId as unknown as Id<"companies">, }) - const rows: Array> = [] - rows.push(["Relatório", "Backlog"]) - rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"]) - if (companyId) rows.push(["EmpresaId", companyId]) - rows.push([]) - rows.push(["Seção", "Chave", "Valor"]) // header + const summaryRows: Array> = [ + ["Relatório", "Backlog"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) + } + summaryRows.push(["Chamados em aberto", report.totalOpen]) + + const distributionRows: Array> = [] - // Status const STATUS_PT: Record = { PENDING: "Pendentes", AWAITING_ATTENDANCE: "Em andamento", @@ -69,10 +72,9 @@ export async function GET(request: Request) { RESOLVED: "Resolvidos", } for (const [status, total] of Object.entries(report.statusCounts)) { - rows.push(["Status", STATUS_PT[status] ?? status, total]) + distributionRows.push(["Status", STATUS_PT[status] ?? status, total]) } - // Prioridade const PRIORITY_PT: Record = { LOW: "Baixa", MEDIUM: "Média", @@ -80,26 +82,37 @@ export async function GET(request: Request) { URGENT: "Crítica", } for (const [priority, total] of Object.entries(report.priorityCounts)) { - rows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total]) + distributionRows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total]) } - // Filas for (const q of report.queueCounts) { - rows.push(["Fila", q.name || q.id, q.total]) + distributionRows.push(["Fila", q.name || q.id, q.total]) } - rows.push(["Abertos", "Total", report.totalOpen]) + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuições", + headers: ["Categoria", "Chave", "Total"], + rows: distributionRows, + }, + ]) - const csv = rowsToCsv(rows) - return new NextResponse(csv, { + const body = new Uint8Array(workbook) + + return new NextResponse(body, { headers: { - "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.xlsx"`, "Cache-Control": "no-store", }, }) } catch (error) { - console.error("Failed to generate backlog CSV", error) - return NextResponse.json({ error: "Falha ao gerar CSV do backlog" }, { status: 500 }) + console.error("Failed to generate backlog export", error) + return NextResponse.json({ error: "Falha ao gerar planilha do backlog" }, { status: 500 }) } } diff --git a/src/app/api/reports/csat.csv/route.ts b/src/app/api/reports/csat.xlsx/route.ts similarity index 55% rename from src/app/api/reports/csat.csv/route.ts rename to src/app/api/reports/csat.xlsx/route.ts index e65c55e..0ca8672 100644 --- a/src/app/api/reports/csat.csv/route.ts +++ b/src/app/api/reports/csat.xlsx/route.ts @@ -6,7 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { rowsToCsv } from "@/lib/csv" +import { buildXlsxWorkbook } from "@/lib/xlsx" export const runtime = "nodejs" @@ -39,7 +39,7 @@ export async function GET(request: Request) { }) viewerId = ensuredUser?._id ?? null } catch (error) { - console.error("Failed to synchronize user with Convex for CSAT CSV", error) + console.error("Failed to synchronize user with Convex for CSAT export", error) return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) } @@ -55,36 +55,56 @@ export async function GET(request: Request) { companyId: companyId as unknown as Id<"companies">, }) - const rows: Array> = [] - rows.push(["Relatório", "CSAT"]) - rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) - if (companyId) rows.push(["EmpresaId", companyId]) - rows.push([]) - rows.push(["Métrica", "Valor"]) // header - rows.push(["CSAT médio", report.averageScore ?? "—"]) - rows.push(["Total de respostas", report.totalSurveys ?? 0]) - rows.push([]) - rows.push(["Distribuição", "Total"]) - for (const entry of report.distribution ?? []) { - rows.push([`Nota ${entry.score}`, entry.total]) - } - rows.push([]) - rows.push(["Recentes", "Nota", "Recebido em"]) - for (const item of report.recent ?? []) { - const date = new Date(item.receivedAt).toISOString() - rows.push([`#${item.reference}`, item.score, date]) + const summaryRows: Array> = [ + ["Relatório", "CSAT"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) } + summaryRows.push(["CSAT médio", report.averageScore ?? "—"]) + summaryRows.push(["Total de respostas", report.totalSurveys ?? 0]) - const csv = rowsToCsv(rows) - return new NextResponse(csv, { + const distributionRows: Array> = (report.distribution ?? []).map((entry) => [ + entry.score, + entry.total, + ]) + + const recentRows: Array> = (report.recent ?? []).map((item) => [ + `#${item.reference}`, + item.score, + new Date(item.receivedAt).toISOString(), + ]) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Métrica", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuição", + headers: ["Nota", "Total"], + rows: distributionRows.length > 0 ? distributionRows : [["—", 0]], + }, + { + name: "Respostas recentes", + headers: ["Ticket", "Nota", "Recebido em"], + rows: recentRows.length > 0 ? recentRows : [["—", "—", "—"]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { headers: { - "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.xlsx"`, "Cache-Control": "no-store", }, }) } catch (error) { - console.error("Failed to generate CSAT CSV", error) - return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 }) + console.error("Failed to generate CSAT export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de CSAT" }, { status: 500 }) } } diff --git a/src/app/api/reports/hours-by-client.csv/route.ts b/src/app/api/reports/hours-by-client.xlsx/route.ts similarity index 56% rename from src/app/api/reports/hours-by-client.csv/route.ts rename to src/app/api/reports/hours-by-client.xlsx/route.ts index b35b131..68b1683 100644 --- a/src/app/api/reports/hours-by-client.csv/route.ts +++ b/src/app/api/reports/hours-by-client.xlsx/route.ts @@ -6,12 +6,9 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { rowsToCsv } from "@/lib/csv" +import { buildXlsxWorkbook } from "@/lib/xlsx" export const runtime = "nodejs" -function msToHours(ms: number) { - return (ms / 3600000).toFixed(2) -} export async function GET(request: Request) { const session = await assertAuthenticatedSession() @@ -49,33 +46,59 @@ export async function GET(request: Request) { range, }) - const rows: Array> = [] - rows.push(["Relatório", "Horas por cliente"]) - rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) - if (q) rows.push(["Filtro", q]) - rows.push([]) - rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"]) + const summaryRows: Array> = [ + ["Relatório", "Horas por cliente"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (q) summaryRows.push(["Filtro", q]) + if (companyId) summaryRows.push(["EmpresaId", companyId]) + summaryRows.push(["Total de clientes", (report.items as Array).length]) + type Item = { companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth: number | null } let items = (report.items as Item[]) if (companyId) items = items.filter((i) => String(i.companyId) === companyId) if (q) items = items.filter((i) => i.name.toLowerCase().includes(q)) - for (const item of items) { - const internalH = msToHours(item.internalMs) - const externalH = msToHours(item.externalMs) - const totalH = msToHours(item.totalMs) - const contracted = item.contractedHoursPerMonth ?? "—" - const pct = item.contractedHoursPerMonth ? ((item.totalMs / 3600000) / item.contractedHoursPerMonth * 100).toFixed(1) + "%" : "—" - rows.push([item.name, item.isAvulso ? "Sim" : "Não", internalH, externalH, totalH, contracted, pct]) - } - const csv = rowsToCsv(rows) - return new NextResponse(csv, { + + const dataRows = items.map((item) => { + const internalHours = item.internalMs / 3600000 + const externalHours = item.externalMs / 3600000 + const totalHours = item.totalMs / 3600000 + const contracted = item.contractedHoursPerMonth + const usagePct = contracted ? (totalHours / contracted) * 100 : null + return [ + item.name, + item.isAvulso ? "Sim" : "Não", + Number(internalHours.toFixed(2)), + Number(externalHours.toFixed(2)), + Number(totalHours.toFixed(2)), + contracted ?? null, + usagePct !== null ? Number(usagePct.toFixed(1)) : null, + ] + }) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Clientes", + headers: ["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"], + rows: dataRows.length > 0 ? dataRows : [["—", "—", 0, 0, 0, null, null]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { headers: { - "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${companyId ? `-${companyId}` : ''}${q ? `-${encodeURIComponent(q)}` : ''}.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${companyId ? `-${companyId}` : ''}${q ? `-${encodeURIComponent(q)}` : ''}.xlsx"`, "Cache-Control": "no-store", }, }) } catch { - return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 }) + return NextResponse.json({ error: "Falha ao gerar planilha de horas por cliente" }, { status: 500 }) } } diff --git a/src/app/api/reports/machines-inventory.xlsx/route.ts b/src/app/api/reports/machines-inventory.xlsx/route.ts new file mode 100644 index 0000000..942dd35 --- /dev/null +++ b/src/app/api/reports/machines-inventory.xlsx/route.ts @@ -0,0 +1,266 @@ +import { NextResponse } from "next/server" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" + +export const runtime = "nodejs" + +type MachineListEntry = { + id: Id<"machines"> + tenantId: string + hostname: string + companyId: Id<"companies"> | null + companySlug: string | null + companyName: string | null + status: string | null + isActive: boolean + lastHeartbeatAt: number | null + persona: string | null + assignedUserName: string | null + assignedUserEmail: string | null + authEmail: string | null + osName: string + osVersion: string | null + architecture: string | null + macAddresses: string[] + serialNumbers: string[] + registeredBy: string | null + createdAt: number + updatedAt: number + token: { expiresAt: number; usageCount: number; lastUsedAt: number | null } | null + inventory: Record | null + linkedUsers?: Array<{ id: string; email: string; name: string }> +} + +function formatIso(value: number | null | undefined): string | null { + if (typeof value !== "number") return null + try { + return new Date(value).toISOString() + } catch { + return null + } +} + +function formatMemory(bytes: unknown): number | null { + if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return null + const gib = bytes / (1024 ** 3) + return Number(gib.toFixed(2)) +} + +function extractPrimaryIp(inventory: Record | null): string | null { + if (!inventory) return null + const network = inventory.network + if (!network) return null + if (Array.isArray(network)) { + for (const entry of network) { + if (entry && typeof entry === "object") { + const candidate = (entry as { ip?: unknown }).ip + if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim() + } + } + } else if (typeof network === "object") { + const record = network as Record + const ip = + typeof record.primaryIp === "string" + ? record.primaryIp + : typeof record.publicIp === "string" + ? record.publicIp + : null + if (ip && ip.trim().length > 0) return ip.trim() + } + return null +} + +function extractHardware(inventory: Record | null) { + if (!inventory) return {} + const hardware = inventory.hardware + if (!hardware || typeof hardware !== "object") return {} + const hw = hardware as Record + return { + vendor: typeof hw.vendor === "string" ? hw.vendor : null, + model: typeof hw.model === "string" ? hw.model : null, + serial: typeof hw.serial === "string" ? hw.serial : null, + cpuType: typeof hw.cpuType === "string" ? hw.cpuType : null, + physicalCores: typeof hw.physicalCores === "number" ? hw.physicalCores : null, + logicalCores: typeof hw.logicalCores === "number" ? hw.logicalCores : null, + memoryBytes: typeof hw.memoryBytes === "number" ? hw.memoryBytes : null, + } +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const { searchParams } = new URL(request.url) + const companyId = searchParams.get("companyId") ?? undefined + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for machines export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + try { + const machines = (await client.query(api.machines.listByTenant, { + tenantId, + includeMetadata: true, + })) as MachineListEntry[] + + const filtered = machines.filter((machine) => { + if (!companyId) return true + return String(machine.companyId ?? "") === companyId || machine.companySlug === companyId + }) + + const statusCounts = filtered.reduce>((acc, machine) => { + const key = machine.status ?? "unknown" + acc[key] = (acc[key] ?? 0) + 1 + return acc + }, {}) + + const summaryRows: Array> = [ + ["Tenant", tenantId], + ["Total de máquinas", filtered.length], + ] + if (companyId) summaryRows.push(["Filtro de empresa", companyId]) + Object.entries(statusCounts).forEach(([status, total]) => { + summaryRows.push([`Status: ${status}`, total]) + }) + + const inventorySheetRows = filtered.map((machine) => { + const inventory = + machine.inventory && typeof machine.inventory === "object" + ? (machine.inventory as Record) + : null + const hardware = extractHardware(inventory) + const primaryIp = extractPrimaryIp(inventory) + const memoryGiB = formatMemory(hardware.memoryBytes) + return [ + machine.hostname, + machine.companyName ?? "—", + machine.status ?? "unknown", + machine.isActive ? "Sim" : "Não", + formatIso(machine.lastHeartbeatAt), + machine.persona ?? null, + machine.assignedUserName ?? null, + machine.assignedUserEmail ?? null, + machine.authEmail ?? null, + machine.osName, + machine.osVersion ?? null, + machine.architecture ?? null, + machine.macAddresses.join(", "), + machine.serialNumbers.join(", "), + machine.registeredBy ?? null, + formatIso(machine.createdAt), + formatIso(machine.updatedAt), + hardware.vendor, + hardware.model, + hardware.serial, + hardware.cpuType, + hardware.physicalCores, + hardware.logicalCores, + memoryGiB, + primaryIp, + machine.token?.expiresAt ? formatIso(machine.token.expiresAt) : null, + machine.token?.usageCount ?? null, + ] + }) + + const linksSheetRows: Array> = [] + filtered.forEach((machine) => { + if (!machine.linkedUsers || machine.linkedUsers.length === 0) return + machine.linkedUsers.forEach((user) => { + linksSheetRows.push([ + machine.hostname, + machine.companyName ?? "—", + user.name ?? user.email ?? "—", + user.email ?? "—", + ]) + }) + }) + + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Máquinas", + headers: [ + "Hostname", + "Empresa", + "Status", + "Ativa", + "Último heartbeat", + "Persona", + "Responsável", + "E-mail responsável", + "E-mail autenticado", + "Sistema operacional", + "Versão SO", + "Arquitetura", + "Endereços MAC", + "Seriais", + "Registrada via", + "Criada em", + "Atualizada em", + "Fabricante", + "Modelo", + "Serial hardware", + "Processador", + "Cores físicas", + "Cores lógicas", + "Memória (GiB)", + "IP principal", + "Token expira em", + "Uso do token", + ], + rows: inventorySheetRows.length > 0 ? inventorySheetRows : [["—"]], + }, + { + name: "Vínculos", + headers: ["Hostname", "Empresa", "Usuário", "E-mail"], + rows: linksSheetRows.length > 0 ? linksSheetRows : [["—", "—", "—", "—"]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="machines-inventory-${tenantId}${companyId ? `-${companyId}` : ""}.xlsx"`, + "Cache-Control": "no-store", + }, + }) + } catch (error) { + console.error("Failed to generate machines inventory export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de inventário" }, { status: 500 }) + } +} diff --git a/src/app/api/reports/sla.csv/route.ts b/src/app/api/reports/sla.xlsx/route.ts similarity index 55% rename from src/app/api/reports/sla.csv/route.ts rename to src/app/api/reports/sla.xlsx/route.ts index 3680850..ebbf2c3 100644 --- a/src/app/api/reports/sla.csv/route.ts +++ b/src/app/api/reports/sla.xlsx/route.ts @@ -6,21 +6,10 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { buildXlsxWorkbook } from "@/lib/xlsx" export const runtime = "nodejs" -function csvEscape(value: unknown): string { - const s = value == null ? "" : String(value) - if (/[",\n]/.test(s)) { - return '"' + s.replace(/"/g, '""') + '"' - } - return s -} - -function rowsToCsv(rows: Array>): string { - return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n" -} - export async function GET(request: Request) { const session = await assertAuthenticatedSession() if (!session) { @@ -50,7 +39,7 @@ export async function GET(request: Request) { }) viewerId = ensuredUser?._id ?? null } catch (error) { - console.error("Failed to synchronize user with Convex for SLA CSV", error) + console.error("Failed to synchronize user with Convex for SLA export", error) return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) } @@ -66,42 +55,55 @@ export async function GET(request: Request) { companyId: companyId as unknown as Id<"companies">, }) - const rows: Array> = [] - rows.push(["Relatório", "Produtividade"]) - rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")]) - if (companyId) rows.push(["EmpresaId", companyId]) - rows.push([]) + const summaryRows: Array> = [ + ["Relatório", "Produtividade"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")], + ] + if (companyId) { + summaryRows.push(["EmpresaId", companyId]) + } + summaryRows.push(["Tickets totais", report.totals.total]) + summaryRows.push(["Tickets abertos", report.totals.open]) + summaryRows.push(["Tickets resolvidos", report.totals.resolved]) + summaryRows.push(["Atrasados (SLA)", report.totals.overdue]) + summaryRows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"]) + summaryRows.push(["Respostas registradas", report.response.responsesRegistered ?? 0]) + summaryRows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"]) + summaryRows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0]) - rows.push(["Métrica", "Valor"]) // header - rows.push(["Tickets totais", report.totals.total]) - rows.push(["Tickets abertos", report.totals.open]) - rows.push(["Tickets resolvidos", report.totals.resolved]) - rows.push(["Atrasados (SLA)", report.totals.overdue]) - rows.push([]) - rows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"]) - rows.push(["Respostas registradas", report.response.responsesRegistered ?? 0]) - rows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"]) - rows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0]) - rows.push([]) - rows.push(["Fila", "Abertos"]) - for (const q of report.queueBreakdown ?? []) { - rows.push([q.name || q.id, q.open]) + const queueRows: Array> = [] + for (const queue of report.queueBreakdown ?? []) { + queueRows.push([queue.name || queue.id, queue.open]) } - const csv = rowsToCsv(rows) + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Indicador", "Valor"], + rows: summaryRows, + }, + { + name: "Filas", + headers: ["Fila", "Chamados abertos"], + rows: queueRows.length > 0 ? queueRows : [["—", 0]], + }, + ]) + const daysLabel = (() => { const raw = (range ?? "90d").replace("d", "") return /^(7|30|90)$/.test(raw) ? `${raw}d` : "all" })() - return new NextResponse(csv, { + const body = new Uint8Array(workbook) + + return new NextResponse(body, { headers: { - "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.xlsx"`, "Cache-Control": "no-store", }, }) } catch (error) { - console.error("Failed to generate SLA CSV", error) - return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 }) + console.error("Failed to generate SLA export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de SLA" }, { status: 500 }) } } diff --git a/src/app/api/reports/tickets-by-channel.csv/route.ts b/src/app/api/reports/tickets-by-channel.xlsx/route.ts similarity index 67% rename from src/app/api/reports/tickets-by-channel.csv/route.ts rename to src/app/api/reports/tickets-by-channel.xlsx/route.ts index e2bf2f4..33fc1db 100644 --- a/src/app/api/reports/tickets-by-channel.csv/route.ts +++ b/src/app/api/reports/tickets-by-channel.xlsx/route.ts @@ -6,7 +6,7 @@ import type { Id } from "@/convex/_generated/dataModel" import { env } from "@/lib/env" import { assertAuthenticatedSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" -import { rowsToCsv } from "@/lib/csv" +import { buildXlsxWorkbook } from "@/lib/xlsx" export const runtime = "nodejs" @@ -39,7 +39,7 @@ export async function GET(request: Request) { }) viewerId = ensuredUser?._id ?? null } catch (error) { - console.error("Failed to synchronize user with Convex for channel CSV", error) + console.error("Failed to synchronize user with Convex for channel export", error) return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) } @@ -66,28 +66,43 @@ export async function GET(request: Request) { WEB: "Portal", PORTAL: "Portal", } + const summaryRows: Array> = [ + ["Relatório", "Tickets por canal"], + ["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"], + ] + if (companyId) summaryRows.push(["EmpresaId", companyId]) + summaryRows.push(["Total de linhas", report.points.length]) + const header = ["Data", ...channels.map((ch) => CHANNEL_PT[ch] ?? ch)] - const rows: Array> = [] - rows.push(["Relatório", "Tickets por canal"]) - rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')]) - rows.push([]) - rows.push(header) - - for (const point of report.points) { + const dataRows: Array> = report.points.map((point) => { const values = channels.map((ch) => point.values[ch] ?? 0) - rows.push([point.date, ...values]) - } + return [point.date, ...values] + }) - const csv = rowsToCsv(rows) - return new NextResponse(csv, { + const workbook = buildXlsxWorkbook([ + { + name: "Resumo", + headers: ["Item", "Valor"], + rows: summaryRows, + }, + { + name: "Distribuição", + headers: header, + rows: dataRows.length > 0 ? dataRows : [[new Date().toISOString().slice(0, 10), ...channels.map(() => 0)]], + }, + ]) + + const body = new Uint8Array(workbook) + + return new NextResponse(body, { headers: { - "Content-Type": "text/csv; charset=UTF-8", - "Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.csv"`, + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.xlsx"`, "Cache-Control": "no-store", }, }) } catch (error) { - console.error("Failed to generate tickets-by-channel CSV", error) - return NextResponse.json({ error: "Falha ao gerar CSV de tickets por canal" }, { status: 500 }) + console.error("Failed to generate tickets-by-channel export", error) + return NextResponse.json({ error: "Falha ao gerar planilha de tickets por canal" }, { status: 500 }) } } diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts index 0373455..a774af0 100644 --- a/src/app/api/tickets/[id]/export/pdf/route.ts +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -14,7 +14,7 @@ import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template" export const runtime = "nodejs" async function readLogoAsDataUrl() { - const logoPath = path.join(process.cwd(), "public", "raven.png") + const logoPath = path.join(process.cwd(), "public", "logo-raven.png") try { const buffer = await fs.promises.readFile(logoPath) const base64 = buffer.toString("base64") diff --git a/src/app/reports/company/page.tsx b/src/app/reports/company/page.tsx new file mode 100644 index 0000000..b8baccf --- /dev/null +++ b/src/app/reports/company/page.tsx @@ -0,0 +1,24 @@ +import { AppShell } from "@/components/app-shell" +import { CompanyReport } from "@/components/reports/company-report" +import { SiteHeader } from "@/components/site-header" +import { requireAuthenticatedSession } from "@/lib/auth-server" + +export const dynamic = "force-dynamic" + +export default async function ReportsCompanyPage() { + await requireAuthenticatedSession() + return ( + + } + > +
+ +
+
+ ) +} diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index ae1be8e..7e9b984 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -88,6 +88,18 @@ type MachineAlertEntry = { severity: string createdAt: number } +type MachineTicketSummary = { + id: string + reference: number + subject: string + status: string + priority: string + updatedAt: number + createdAt: number + machine: { id: string | null; hostname: string | null } | null + assignee: { name: string | null; email: string | null } | null +} + type DetailLineProps = { label: string @@ -774,6 +786,13 @@ const statusLabels: Record = { unknown: "Desconhecida", } +const TICKET_STATUS_LABELS: Record = { + PENDING: "Pendente", + AWAITING_ATTENDANCE: "Em andamento", + PAUSED: "Pausado", + RESOLVED: "Resolvido", +} + const statusClasses: Record = { online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600", offline: "border-rose-500/20 bg-rose-500/15 text-rose-600", @@ -1109,7 +1128,16 @@ export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "al .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) }, [companies, machines]) -const filteredMachines = useMemo(() => { + const exportHref = useMemo(() => { + const params = new URLSearchParams() + if (companyFilterSlug !== "all") { + params.set("companyId", companyFilterSlug) + } + const qs = params.toString() + return `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}` + }, [companyFilterSlug]) + + const filteredMachines = useMemo(() => { const text = q.trim().toLowerCase() return machines.filter((m) => { if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false @@ -1202,6 +1230,11 @@ const filteredMachines = useMemo(() => { Somente com alertas + {isLoading ? ( @@ -1305,6 +1338,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) { machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const) ) as MachineAlertEntry[] | undefined const machineAlertsHistory = alertsHistory ?? [] + const openTickets = useQuery( + machine ? api.machines.listOpenTickets : "skip", + machine ? { machineId: machine.id as Id<"machines">, limit: 8 } : ("skip" as const) + ) as MachineTicketSummary[] | undefined + const machineTickets = openTickets ?? [] const metadata = machine?.inventory ?? null const metrics = machine?.metrics ?? null const metricsCapturedAt = useMemo(() => getMetricsTimestamp(metrics), [metrics]) @@ -2175,6 +2213,43 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ))} +
+
+

Tickets abertos por esta máquina

+ + {machineTickets.length} + +
+ {machineTickets.length === 0 ? ( +

Nenhum chamado em aberto registrado diretamente por esta máquina.

+ ) : ( +
    + {machineTickets.map((ticket) => ( +
  • +
    +

    + #{ticket.reference} · {ticket.subject} +

    +

    + Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} +

    +
    +
    + + {ticket.priority} + + + {TICKET_STATUS_LABELS[ticket.status] ?? ticket.status} + +
    +
  • + ))} +
+ )} +
{machine.authEmail ? (
-
+
@@ -599,6 +667,8 @@ function AccountsTable({ /> Usuário + Cargo + Gestor Empresa Papel Último acesso @@ -608,7 +678,7 @@ function AccountsTable({ {filteredAccounts.length === 0 ? ( - + Nenhum usuário encontrado. @@ -642,6 +712,25 @@ function AccountsTable({ + + {account.jobTitle ? ( + account.jobTitle + ) : ( + Sem cargo + )} + + + {account.managerName ? ( +
+ {account.managerName} + {account.managerEmail ? ( + {account.managerEmail} + ) : null} +
+ ) : ( + Sem gestor + )} +
{account.companyName ?? Sem empresa} @@ -778,6 +867,34 @@ function AccountsTable({ disabled={isSavingAccount} /> +
+ + setEditForm((prev) => ({ ...prev, jobTitle: event.target.value }))} + placeholder="Cargo ou função" + disabled={isSavingAccount} + /> +
+
+ + + setEditForm((prev) => ({ + ...prev, + managerId: next === null || next === NO_MANAGER_SELECT_VALUE ? "" : next, + })) + } + options={editManagerOptions} + placeholder="Sem gestor definido" + searchPlaceholder="Buscar gestor..." + allowClear + clearLabel="Remover gestor" + disabled={isSavingAccount} + /> +
@@ -871,6 +988,34 @@ function AccountsTable({ required />
+
+ + setCreateForm((prev) => ({ ...prev, jobTitle: event.target.value }))} + placeholder="Ex.: Analista de Suporte" + disabled={isCreatingAccount} + /> +
+
+ + + setCreateForm((prev) => ({ + ...prev, + managerId: value === null ? NO_MANAGER_SELECT_VALUE : value, + })) + } + options={managerOptions} + placeholder="Sem gestor definido" + searchPlaceholder="Buscar gestor..." + allowClear + clearLabel="Remover gestor" + disabled={isCreatingAccount} + /> +
+ + + + + {(companies ?? []).map((company) => ( + + {company.name} + + ))} + + + +
+ + + {!report || isLoading ? ( +
+ + +
+ + +
+
+ ) : ( + <> +
+ + + Tickets em aberto + Chamados associados a esta empresa. + + {openTicketCount} + + + + Máquinas monitoradas + Inventário registrado nesta empresa. + + {report.machines.total} + + + + Colaboradores ativos + Usuários vinculados à empresa. + + {report.users.total} + +
+ + + +
+ Abertura x resolução de tickets + + Comparativo diário no período selecionado. + +
+
+ + + + + + + + + + + + + + + { + const date = new Date(value) + return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" }) + }} + /> + + new Date(value).toLocaleDateString("pt-BR", { + day: "2-digit", + month: "long", + year: "numeric", + }) + } + /> + } + /> + + + } /> + + + +
+ +
+ + + Distribuição de status dos tickets + Status atuais dos chamados da empresa. + + + + + + STATUS_LABELS[value] ?? value} + /> + STATUS_LABELS[value] ?? value} + /> + } + /> + + + + + + + + + Sistemas operacionais + Inventário das máquinas desta empresa. + + + + + } /> + + + + + +
+ + + + Tickets recentes (máximo 6) + + Chamados em aberto para a empresa filtrada. + + + + {openTickets.length === 0 ? ( +

+ Nenhum chamado aberto no período selecionado. +

+ ) : ( +
    + {openTickets.map((ticket) => ( +
  • +
    +

    + #{ticket.reference} · {ticket.subject} +

    +

    + Atualizado{" "} + {new Date(ticket.updatedAt).toLocaleString("pt-BR", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + })} +

    +
    +
    + + {ticket.priority} + + + {STATUS_LABELS[ticket.status] ?? ticket.status} + +
    +
  • + ))} +
+ )} +
+
+ + )} + + ) +} diff --git a/src/components/reports/csat-report.tsx b/src/components/reports/csat-report.tsx index 2963e97..311df87 100644 --- a/src/components/reports/csat-report.tsx +++ b/src/components/reports/csat-report.tsx @@ -82,8 +82,8 @@ export function CsatReport() { diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx index 7207494..0ba27c7 100644 --- a/src/components/reports/hours-report.tsx +++ b/src/components/reports/hours-report.tsx @@ -193,10 +193,10 @@ export function HoursReport() { diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index 4e44b7f..51b54f3 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -164,8 +164,8 @@ export function SlaReport() { diff --git a/src/components/tickets/ticket-timeline.tsx b/src/components/tickets/ticket-timeline.tsx index 303d51d..b6a781e 100644 --- a/src/components/tickets/ticket-timeline.tsx +++ b/src/components/tickets/ticket-timeline.tsx @@ -258,13 +258,21 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) { message = "Fila alterada" + (payload.queueName ? " para " + payload.queueName : "") } if (entry.type === "REQUESTER_CHANGED") { - const payloadRequest = payload as { requesterName?: string | null; requesterEmail?: string | null; requesterId?: string | null; companyName?: string | null } - const name = payloadRequest.requesterName || payloadRequest.requesterEmail || payloadRequest.requesterId - const company = payloadRequest.companyName - if (name && company) { - message = `Solicitante alterado para ${name} (${company})` - } else if (name) { - message = `Solicitante alterado para ${name}` + const payloadRequest = payload as { + requesterName?: string | null + requesterEmail?: string | null + requesterId?: string | null + companyName?: string | null + } + const name = payloadRequest.requesterName?.trim() + const email = payloadRequest.requesterEmail?.trim() + const fallback = payloadRequest.requesterId ?? null + const identifier = name ? (email ? `${name} · ${email}` : name) : email ?? fallback + const company = payloadRequest.companyName?.trim() + if (identifier && company) { + message = `Solicitante alterado para ${identifier} • Empresa: ${company}` + } else if (identifier) { + message = `Solicitante alterado para ${identifier}` } else if (company) { message = `Solicitante associado à empresa ${company}` } else { diff --git a/src/lib/mappers/ticket.ts b/src/lib/mappers/ticket.ts index 35b56a8..0d6e527 100644 --- a/src/lib/mappers/ticket.ts +++ b/src/lib/mappers/ticket.ts @@ -30,6 +30,15 @@ const serverUserSchema = z.object({ teams: z.array(z.string()).optional(), }); +const serverMachineSummarySchema = z.object({ + id: z.string().nullable().optional(), + hostname: z.string().nullable().optional(), + persona: z.string().nullable().optional(), + assignedUserName: z.string().nullable().optional(), + assignedUserEmail: z.string().nullable().optional(), + status: z.string().nullable().optional(), +}); + const serverTicketSchema = z.object({ id: z.string(), reference: z.number(), @@ -46,6 +55,7 @@ const serverTicketSchema = z.object({ .object({ id: z.string(), name: z.string(), isAvulso: z.boolean().optional() }) .optional() .nullable(), + machine: serverMachineSummarySchema.optional().nullable(), slaPolicy: z.any().nullable().optional(), dueAt: z.number().nullable().optional(), firstResponseAt: z.number().nullable().optional(), @@ -151,6 +161,16 @@ export function mapTicketFromServer(input: unknown) { company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined, + machine: s.machine + ? { + id: s.machine.id ?? null, + hostname: s.machine.hostname ?? null, + persona: s.machine.persona ?? null, + assignedUserName: s.machine.assignedUserName ?? null, + assignedUserEmail: s.machine.assignedUserEmail ?? null, + status: s.machine.status ?? null, + } + : null, category: s.category ?? undefined, subcategory: s.subcategory ?? undefined, lastTimelineEntry: s.lastTimelineEntry ?? undefined, @@ -223,6 +243,16 @@ export function mapTicketWithDetailsFromServer(input: unknown) { firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null, resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null, company: s.company ? { id: s.company.id, name: s.company.name, isAvulso: s.company.isAvulso ?? false } : undefined, + machine: s.machine + ? { + id: s.machine.id ?? null, + hostname: s.machine.hostname ?? null, + persona: s.machine.persona ?? null, + assignedUserName: s.machine.assignedUserName ?? null, + assignedUserEmail: s.machine.assignedUserEmail ?? null, + status: s.machine.status ?? null, + } + : null, timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })), comments: s.comments.map((c) => ({ ...c, diff --git a/src/lib/schemas/ticket.ts b/src/lib/schemas/ticket.ts index fb4fbc7..397e98f 100644 --- a/src/lib/schemas/ticket.ts +++ b/src/lib/schemas/ticket.ts @@ -41,6 +41,16 @@ export const ticketCompanySummarySchema = z.object({ }) export type TicketCompanySummary = z.infer +export const ticketMachineSummarySchema = z.object({ + id: z.string().nullable(), + hostname: z.string().nullable().optional(), + persona: z.string().nullable().optional(), + assignedUserName: z.string().nullable().optional(), + assignedUserEmail: z.string().nullable().optional(), + status: z.string().nullable().optional(), +}) +export type TicketMachineSummary = z.infer + export const ticketCategorySummarySchema = z.object({ id: z.string(), name: z.string(), @@ -118,6 +128,7 @@ export const ticketSchema = z.object({ requester: userSummarySchema, assignee: userSummarySchema.nullable(), company: ticketCompanySummarySchema.optional().nullable(), + machine: ticketMachineSummarySchema.nullable().optional(), slaPolicy: z .object({ id: z.string(), diff --git a/src/lib/xlsx.ts b/src/lib/xlsx.ts new file mode 100644 index 0000000..bc112a4 --- /dev/null +++ b/src/lib/xlsx.ts @@ -0,0 +1,327 @@ +import { TextEncoder } from "util" + +type WorksheetRow = Array + +export type WorksheetConfig = { + name: string + headers: string[] + rows: WorksheetRow[] +} + +type ZipEntry = { + path: string + data: Buffer +} + +const XML_DECLARATION = '' + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/\u0008/g, "") + .replace(/\u000B/g, "") + .replace(/\u000C/g, "") + .replace(/\u0000/g, "") +} + +function columnRef(index: number): string { + let col = "" + let n = index + 1 + while (n > 0) { + const remainder = (n - 1) % 26 + col = String.fromCharCode(65 + remainder) + col + n = Math.floor((n - 1) / 26) + } + return col +} + +function formatCell(value: unknown, colIndex: number, rowIndex: number): string { + const ref = `${columnRef(colIndex)}${rowIndex + 1}` + if (value === null || value === undefined || value === "") { + return `` + } + + if (value instanceof Date) { + return `${escapeXml(value.toISOString())}` + } + + if (typeof value === "number" && Number.isFinite(value)) { + return `${value}` + } + + if (typeof value === "boolean") { + return `${value ? 1 : 0}` + } + + let text: string + if (typeof value === "string") { + text = value + } else { + text = JSON.stringify(value) + } + return `${escapeXml(text)}` +} + +function buildWorksheetXml(config: WorksheetConfig): string { + const rows: string[] = [] + const headerRow = config.headers.map((header, idx) => formatCell(header, idx, 0)).join("") + rows.push(`${headerRow}`) + + config.rows.forEach((rowData, rowIdx) => { + const cells = config.headers.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, rowIdx + 1)).join("") + rows.push(`${cells}`) + }) + + return [ + XML_DECLARATION, + '', + " ", + rows.map((row) => ` ${row}`).join("\n"), + " ", + "", + ].join("\n") +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256) + for (let i = 0; i < 256; i += 1) { + let c = i + for (let k = 0; k < 8; k += 1) { + c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[i] = c >>> 0 + } + return table +})() + +function crc32(buffer: Buffer): number { + let crc = 0 ^ -1 + for (let i = 0; i < buffer.length; i += 1) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buffer[i]!) & 0xff] + } + return (crc ^ -1) >>> 0 +} + +function toDosTime(date: Date): { time: number; date: number } { + const year = date.getFullYear() + const month = date.getMonth() + 1 + const day = date.getDate() + const hours = date.getHours() + const minutes = date.getMinutes() + const seconds = Math.floor(date.getSeconds() / 2) + const dosTime = (hours << 11) | (minutes << 5) | seconds + const dosDate = ((year - 1980) << 9) | (month << 5) | day + return { time: dosTime, date: dosDate } +} + +function writeUInt16LE(value: number): Buffer { + const buffer = Buffer.alloc(2) + buffer.writeUInt16LE(value & 0xffff, 0) + return buffer +} + +function writeUInt32LE(value: number): Buffer { + const buffer = Buffer.alloc(4) + buffer.writeUInt32LE(value >>> 0, 0) + return buffer +} + +function encodeZip(entries: ZipEntry[]): Buffer { + const encoder = new TextEncoder() + const localParts: Buffer[] = [] + const centralParts: Buffer[] = [] + let offset = 0 + const now = new Date() + const { time: dosTime, date: dosDate } = toDosTime(now) + + entries.forEach((entry) => { + const filenameBytes = encoder.encode(entry.path) + const crc = crc32(entry.data) + const compressedSize = entry.data.length + const uncompressedSize = entry.data.length + + const localHeader = Buffer.concat([ + writeUInt32LE(0x04034b50), + writeUInt16LE(20), + writeUInt16LE(0), + writeUInt16LE(0), // store (no compression) + writeUInt16LE(dosTime), + writeUInt16LE(dosDate), + writeUInt32LE(crc), + writeUInt32LE(compressedSize), + writeUInt32LE(uncompressedSize), + writeUInt16LE(filenameBytes.length), + writeUInt16LE(0), + Buffer.from(filenameBytes), + ]) + + localParts.push(localHeader, entry.data) + + const centralHeader = Buffer.concat([ + writeUInt32LE(0x02014b50), + writeUInt16LE(20), + writeUInt16LE(20), + writeUInt16LE(0), + writeUInt16LE(0), + writeUInt16LE(dosTime), + writeUInt16LE(dosDate), + writeUInt32LE(crc), + writeUInt32LE(compressedSize), + writeUInt32LE(uncompressedSize), + writeUInt16LE(filenameBytes.length), + writeUInt16LE(0), + writeUInt16LE(0), + writeUInt16LE(0), + writeUInt16LE(0), + writeUInt32LE(0), + writeUInt32LE(offset), + Buffer.from(filenameBytes), + ]) + + centralParts.push(centralHeader) + + offset += localHeader.length + entry.data.length + }) + + const centralDirectory = Buffer.concat(centralParts) + const endOfCentralDirectory = Buffer.concat([ + writeUInt32LE(0x06054b50), + writeUInt16LE(0), + writeUInt16LE(0), + writeUInt16LE(entries.length), + writeUInt16LE(entries.length), + writeUInt32LE(centralDirectory.length), + writeUInt32LE(offset), + writeUInt16LE(0), + ]) + + return Buffer.concat([...localParts, centralDirectory, endOfCentralDirectory]) +} + +export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer { + if (sheets.length === 0) { + throw new Error("Workbook requires at least one sheet") + } + + const now = new Date() + const timestamp = now.toISOString() + const workbookRels: string[] = [] + const sheetEntries: ZipEntry[] = [] + + const sheetRefs = sheets.map((sheet, index) => { + const sheetId = index + 1 + const relId = `rId${sheetId}` + const worksheetXml = buildWorksheetXml(sheet) + sheetEntries.push({ + path: `xl/worksheets/sheet${sheetId}.xml`, + data: Buffer.from(worksheetXml, "utf8"), + }) + workbookRels.push( + ``, + ) + return `` + }) + + const workbookXml = [ + XML_DECLARATION, + '', + " ", + sheetRefs.map((sheet) => ` ${sheet}`).join("\n"), + " ", + "", + ].join("\n") + + workbookRels.push( + '', + ) + + const workbookRelsXml = [ + XML_DECLARATION, + '', + workbookRels.map((rel) => ` ${rel}`).join("\n"), + "", + ].join("\n") + + const stylesXml = [ + XML_DECLARATION, + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + "", + ].join("\n") + + const corePropsXml = [ + XML_DECLARATION, + '', + " Raven", + " Raven", + ` ${escapeXml(timestamp)}`, + ` ${escapeXml(timestamp)}`, + "", + ].join("\n") + + const appPropsXml = [ + XML_DECLARATION, + '', + " Raven", + " 0", + " false", + " ", + ' ', + ' Worksheets', + ` ${sheets.length}`, + " ", + " ", + " ", + ` `, + sheets.map((sheet) => ` ${escapeXml(sheet.name)}`).join("\n"), + " ", + " ", + "", + ].join("\n") + + const contentTypes = [ + XML_DECLARATION, + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ...sheets.map( + (_sheet, index) => + ` `, + ), + "", + ].join("\n") + + const rootRelsXml = [ + XML_DECLARATION, + '', + ' ', + ' ', + ' ', + "", + ].join("\n") + + const entries: ZipEntry[] = [ + { path: "[Content_Types].xml", data: Buffer.from(contentTypes, "utf8") }, + { path: "_rels/.rels", data: Buffer.from(rootRelsXml, "utf8") }, + { path: "docProps/core.xml", data: Buffer.from(corePropsXml, "utf8") }, + { path: "docProps/app.xml", data: Buffer.from(appPropsXml, "utf8") }, + { path: "xl/workbook.xml", data: Buffer.from(workbookXml, "utf8") }, + { path: "xl/_rels/workbook.xml.rels", data: Buffer.from(workbookRelsXml, "utf8") }, + { path: "xl/styles.xml", data: Buffer.from(stylesXml, "utf8") }, + ...sheetEntries, + ] + + return encodeZip(entries) +} diff --git a/src/server/pdf/ticket-pdf-template.tsx b/src/server/pdf/ticket-pdf-template.tsx index a5ba478..28e55b7 100644 --- a/src/server/pdf/ticket-pdf-template.tsx +++ b/src/server/pdf/ticket-pdf-template.tsx @@ -255,6 +255,8 @@ function buildTimelineMessage(type: string, payload: Record | n const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined) const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined) const requester = p.requesterName as string | undefined + const requesterEmail = p.requesterEmail as string | undefined + const requesterId = p.requesterId as string | undefined const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined) const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined) const attachmentName = p.attachmentName as string | undefined @@ -283,6 +285,21 @@ function buildTimelineMessage(type: string, payload: Record | n const who = actor ?? author return who ? `Comentário editado por ${who}` : "Comentário editado" } + case "REQUESTER_CHANGED": { + let display: string | null = null + if (requester) { + display = requesterEmail ? `${requester} (${requesterEmail})` : requester + } else if (requesterEmail) { + display = requesterEmail + } else if (requesterId) { + display = requesterId + } + const companyName = (p.companyName as string | undefined) ?? null + if (display && companyName) return `Solicitante alterado para ${display} • Empresa: ${companyName}` + if (display) return `Solicitante alterado para ${display}` + if (companyName) return `Solicitante associado à empresa ${companyName}` + return "Solicitante alterado" + } case "SUBJECT_CHANGED": return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado" case "SUMMARY_CHANGED": @@ -389,6 +406,10 @@ function TicketPdfDocument({ ticket, logoDataUrl }: { ticket: TicketWithDetails; { label: "Criado em", value: formatDateTime(ticket.createdAt) }, { label: "Atualizado em", value: formatDateTime(ticket.updatedAt) }, ] + if (ticket.machine) { + const machineLabel = ticket.machine.hostname ?? (ticket.machine.id ? `ID ${ticket.machine.id}` : "—") + rightMeta.push({ label: "Máquina", value: machineLabel }) + } if (ticket.resolvedAt) { rightMeta.push({ label: "Resolvido em", value: formatDateTime(ticket.resolvedAt) }) }