feat: export reports as xlsx and add machine inventory

This commit is contained in:
Esdras Renan 2025-10-27 18:00:28 -03:00
parent 29b865885c
commit 714b199879
34 changed files with 2304 additions and 245 deletions

View file

@ -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.

View file

@ -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"),

View file

@ -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<string, number>;
const priorityCounts = {} as Record<string, number>;
const channelCounts = {} as Record<string, number>;
const trendMap = new Map<string, { opened: number; resolved: number }>();
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<string, number> = {};
const machineOsCounts: Record<string, number> = {};
const machineLookup = new Map<string, Doc<"machines">>();
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<string, number> = {};
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,
},
};
},
});

View file

@ -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({

View file

@ -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<DataModel, "tickets">;
type TicketsQueryBuilder = ConvexQuery<TicketsTableInfo>;
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<string>();
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<string, Doc<"ticketCategories"> | null>();
const subcategoryCache = new Map<string, Doc<"ticketSubcategories"> | null>();
const machineCache = new Map<string, Doc<"machines"> | 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",

View file

@ -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<string, unknown> = {
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" };
},

View file

@ -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;

View file

@ -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 {

View file

@ -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(),

View file

@ -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,
},
})
}

View file

@ -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,
})

View file

@ -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<Array<unknown>> = []
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<Array<unknown>> = [
["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<Array<unknown>> = []
// Status
const STATUS_PT: Record<string, string> = {
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<string, string> = {
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 })
}
}

View file

@ -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<Array<unknown>> = []
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<Array<unknown>> = [
["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<Array<unknown>> = (report.distribution ?? []).map((entry) => [
entry.score,
entry.total,
])
const recentRows: Array<Array<unknown>> = (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 })
}
}

View file

@ -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<Array<unknown>> = []
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<Array<unknown>> = [
["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<unknown>).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 })
}
}

View file

@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>
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<string, unknown> | null) {
if (!inventory) return {}
const hardware = inventory.hardware
if (!hardware || typeof hardware !== "object") return {}
const hw = hardware as Record<string, unknown>
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<Record<string, number>>((acc, machine) => {
const key = machine.status ?? "unknown"
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
const summaryRows: Array<Array<unknown>> = [
["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<string, unknown>)
: 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<Array<unknown>> = []
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 })
}
}

View file

@ -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<Array<unknown>>): 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<Array<unknown>> = []
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<Array<unknown>> = [
["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<Array<unknown>> = []
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 })
}
}

View file

@ -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<Array<unknown>> = [
["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<Array<unknown>> = []
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<Array<unknown>> = 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 })
}
}

View file

@ -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")

View file

@ -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 (
<AppShell
header={
<SiteHeader
title="Visão por Empresa"
lead="Monitore tickets, inventário e colaboradores de um cliente específico em um só lugar."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">
<CompanyReport />
</div>
</AppShell>
)
}

View file

@ -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<string, string> = {
unknown: "Desconhecida",
}
const TICKET_STATUS_LABELS: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const statusClasses: Record<string, string> = {
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(() => {
<span>Somente com alertas</span>
</label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setCompanyFilterSlug("all"); setCompanySearch(""); setOnlyAlerts(false) }}>Limpar</Button>
<Button asChild size="sm" variant="outline">
<a href={exportHref} download>
Exportar XLSX
</a>
</Button>
</div>
{isLoading ? (
<LoadingState />
@ -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) {
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))}
</div>
<div className="rounded-2xl border border-indigo-100 bg-indigo-50/40 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-semibold text-indigo-900">Tickets abertos por esta máquina</h4>
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold uppercase tracking-wide text-indigo-700">
{machineTickets.length}
</Badge>
</div>
{machineTickets.length === 0 ? (
<p className="mt-3 text-xs text-indigo-700">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
) : (
<ul className="mt-3 space-y-2">
{machineTickets.map((ticket) => (
<li
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-indigo-200 bg-white px-3 py-2 text-sm shadow-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado {formatRelativeTime(new Date(ticket.updatedAt))}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{TICKET_STATUS_LABELS[ticket.status] ?? ticket.status}
</Badge>
</div>
</li>
))}
</ul>
)}
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">

View file

@ -72,6 +72,10 @@ export type AdminAccount = {
role: "MANAGER" | "COLLABORATOR"
companyId: string | null
companyName: string | null
jobTitle: string | null
managerId: string | null
managerName: string | null
managerEmail: string | null
tenantId: string
createdAt: string
updatedAt: string
@ -110,6 +114,7 @@ const ROLE_OPTIONS_DISPLAY: ReadonlyArray<{ value: AdminAccount["role"]; label:
const DEFAULT_CREATE_ROLE: AdminAccount["role"] = "COLLABORATOR"
const NO_COMPANY_SELECT_VALUE = "__none__"
const NO_MANAGER_SELECT_VALUE = "__no_manager__"
const NO_CONTACT_VALUE = "__none__"
@ -118,6 +123,8 @@ type CreateAccountFormState = {
email: string
role: AdminAccount["role"]
companyId: string
jobTitle: string
managerId: string
}
function createDefaultAccountForm(): CreateAccountFormState {
@ -126,6 +133,8 @@ function createDefaultAccountForm(): CreateAccountFormState {
email: "",
role: DEFAULT_CREATE_ROLE,
companyId: NO_COMPANY_SELECT_VALUE,
jobTitle: "",
managerId: NO_MANAGER_SELECT_VALUE,
}
}
@ -188,6 +197,8 @@ function AccountsTable({
email: "",
role: "COLLABORATOR" as AdminAccount["role"],
companyId: "",
jobTitle: "",
managerId: "",
})
const [isSavingAccount, setIsSavingAccount] = useState(false)
const [isResettingPassword, setIsResettingPassword] = useState(false)
@ -207,6 +218,9 @@ function AccountsTable({
return (
account.name.toLowerCase().includes(term) ||
account.email.toLowerCase().includes(term) ||
(account.jobTitle ?? "").toLowerCase().includes(term) ||
(account.managerName ?? "").toLowerCase().includes(term) ||
(account.managerEmail ?? "").toLowerCase().includes(term) ||
(account.companyName ?? "").toLowerCase().includes(term)
)
})
@ -256,6 +270,34 @@ function AccountsTable({
[companies],
)
const managerOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [
{ value: NO_MANAGER_SELECT_VALUE, label: "Sem gestor", keywords: ["nenhum", "sem"] },
]
const managers = accounts
.filter((account) => account.role === "MANAGER")
.map((manager) => {
const keywords: string[] = [manager.email]
if (manager.jobTitle) {
keywords.push(manager.jobTitle)
}
return {
value: manager.id,
label: manager.name,
description: manager.jobTitle ? `${manager.jobTitle}${manager.email}` : manager.email,
keywords,
}
})
return [...base, ...managers]
}, [accounts])
const editManagerOptions = useMemo<SearchableComboboxOption[]>(() => {
if (!editAccountId) return managerOptions
return managerOptions.filter(
(option) => option.value === NO_MANAGER_SELECT_VALUE || option.value !== editAccountId,
)
}, [managerOptions, editAccountId])
const openDeleteDialog = useCallback((ids: string[]) => {
setDeleteDialogIds(ids)
}, [])
@ -298,6 +340,17 @@ function AccountsTable({
email: editAccount.email,
role: editAccount.role,
companyId: editAccount.companyId ?? "",
jobTitle: editAccount.jobTitle ?? "",
managerId: editAccount.managerId ?? "",
})
} else {
setEditForm({
name: "",
email: "",
role: "COLLABORATOR" as AdminAccount["role"],
companyId: "",
jobTitle: "",
managerId: "",
})
}
}, [editAccount])
@ -340,12 +393,21 @@ function AccountsTable({
return
}
const jobTitleValue = editForm.jobTitle.trim()
const managerIdValue = editForm.managerId ? editForm.managerId : null
if (managerIdValue && managerIdValue === editAccount.id) {
toast.error("Um usuário não pode ser gestor de si mesmo.")
return
}
const payload = {
name: editForm.name.trim(),
email: editForm.email.trim().toLowerCase(),
role: ROLE_TO_OPTION[editForm.role] ?? "collaborator",
tenantId: editAccount.tenantId,
companyId: editForm.companyId ? editForm.companyId : null,
jobTitle: jobTitleValue.length > 0 ? jobTitleValue : null,
managerId: managerIdValue,
}
if (!payload.name) {
@ -424,11 +486,17 @@ function AccountsTable({
return
}
const jobTitleValue = createForm.jobTitle.trim()
const managerId =
createForm.managerId && createForm.managerId !== NO_MANAGER_SELECT_VALUE ? createForm.managerId : null
const payload = {
name,
email,
role: ROLE_TO_OPTION[createForm.role] ?? "collaborator",
tenantId: effectiveTenantId,
jobTitle: jobTitleValue.length > 0 ? jobTitleValue : null,
managerId,
}
setIsCreatingAccount(true)
@ -587,7 +655,7 @@ function AccountsTable({
</div>
<div className="overflow-x-auto">
<div className="min-w-[64rem] overflow-hidden rounded-lg border">
<div className="min-w-[80rem] overflow-hidden rounded-lg border">
<Table className="w-full table-fixed text-sm">
<TableHeader className="bg-muted">
<TableRow>
@ -599,6 +667,8 @@ function AccountsTable({
/>
</TableHead>
<TableHead className="min-w-[220px] px-4">Usuário</TableHead>
<TableHead className="px-4">Cargo</TableHead>
<TableHead className="px-4">Gestor</TableHead>
<TableHead className="px-4">Empresa</TableHead>
<TableHead className="px-4">Papel</TableHead>
<TableHead className="px-4">Último acesso</TableHead>
@ -608,7 +678,7 @@ function AccountsTable({
<TableBody>
{filteredAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="px-6 py-6 text-center text-sm text-muted-foreground">
<TableCell colSpan={8} className="px-6 py-6 text-center text-sm text-muted-foreground">
Nenhum usuário encontrado.
</TableCell>
</TableRow>
@ -642,6 +712,25 @@ function AccountsTable({
</div>
</div>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{account.jobTitle ? (
account.jobTitle
) : (
<span className="italic text-muted-foreground/70">Sem cargo</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{account.managerName ? (
<div className="flex flex-col">
<span>{account.managerName}</span>
{account.managerEmail ? (
<span className="text-xs text-muted-foreground">{account.managerEmail}</span>
) : null}
</div>
) : (
<span className="italic text-muted-foreground/70">Sem gestor</span>
)}
</TableCell>
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
{account.companyName ?? <span className="italic text-muted-foreground/70">Sem empresa</span>}
</TableCell>
@ -778,6 +867,34 @@ function AccountsTable({
disabled={isSavingAccount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-job-title">Cargo</Label>
<Input
id="edit-job-title"
value={editForm.jobTitle}
onChange={(event) => setEditForm((prev) => ({ ...prev, jobTitle: event.target.value }))}
placeholder="Cargo ou função"
disabled={isSavingAccount}
/>
</div>
<div className="grid gap-2">
<Label>Gestor direto</Label>
<SearchableCombobox
value={editForm.managerId ? editForm.managerId : NO_MANAGER_SELECT_VALUE}
onValueChange={(next) =>
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}
/>
</div>
</div>
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
@ -871,6 +988,34 @@ function AccountsTable({
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="create-job-title">Cargo</Label>
<Input
id="create-job-title"
value={createForm.jobTitle}
onChange={(event) => setCreateForm((prev) => ({ ...prev, jobTitle: event.target.value }))}
placeholder="Ex.: Analista de Suporte"
disabled={isCreatingAccount}
/>
</div>
<div className="grid gap-2">
<Label>Gestor direto</Label>
<SearchableCombobox
value={createForm.managerId}
onValueChange={(value) =>
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}
/>
</div>
<div className="grid gap-2">
<Label>Papel</Label>
<Select

View file

@ -85,6 +85,7 @@ const navigation: NavigationGroup[] = [
{ title: "Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
{ title: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
],
},

View file

@ -187,10 +187,10 @@ export function ChartAreaInteractive() {
{/* Export button aligned at the end */}
<Button asChild size="sm" variant="outline" className="sm:ml-1">
<a
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
href={`/api/reports/tickets-by-channel.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
download
>
Exportar CSV
Exportar XLSX
</a>
</Button>
</div>

View file

@ -99,6 +99,7 @@ export function PortalTicketForm() {
requesterId: viewerId,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
})
if (plainDescription.length > 0) {

View file

@ -141,8 +141,8 @@ export function BacklogReport() {
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/backlog.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar CSV
<a href={`/api/reports/backlog.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>

View file

@ -0,0 +1,355 @@
"use client"
import { useEffect, useState } from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Area, AreaChart, CartesianGrid, XAxis, Bar, BarChart, Pie, PieChart } from "recharts"
type CompanyRecord = { id: Id<"companies">; name: string }
const STATUS_LABELS: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
const TICKET_TREND_CONFIG = {
opened: {
label: "Abertos",
color: "var(--chart-1)",
},
resolved: {
label: "Resolvidos",
color: "var(--chart-2)",
},
} satisfies ChartConfig
const MACHINE_STATUS_CONFIG = {
online: { label: "Online", color: "var(--chart-1)" },
offline: { label: "Offline", color: "var(--chart-2)" },
stale: { label: "Sem sinal", color: "var(--chart-3)" },
maintenance: { label: "Manutenção", color: "var(--chart-4)" },
blocked: { label: "Bloqueada", color: "var(--chart-5)" },
deactivated: { label: "Desativada", color: "var(--chart-6)" },
unknown: { label: "Desconhecida", color: "var(--muted)" },
} satisfies ChartConfig
export function CompanyReport() {
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const [selectedCompany, setSelectedCompany] = useState<string>("")
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d")
const companies = useQuery(api.companies.list, isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as CompanyRecord[] | undefined
useEffect(() => {
if (!selectedCompany && companies && companies.length > 0) {
setSelectedCompany(companies[0].id)
}
}, [companies, selectedCompany])
const report = useQuery(
api.reports.companyOverview,
selectedCompany && convexUserId && isStaff
? {
tenantId,
viewerId: convexUserId as Id<"users">,
companyId: selectedCompany as Id<"companies">,
range: timeRange,
}
: "skip"
)
const isLoading = selectedCompany !== "" && report === undefined
const openTickets = ((report?.tickets.open ?? []) as Array<{
id: string
reference: number
subject: string
status: string
priority: string
updatedAt: number
}>).slice(0, 6)
const openTicketCount = (() => {
const source = report?.tickets.byStatus ?? {}
return Object.entries(source).reduce((acc, [status, total]) => {
if (status === "RESOLVED") return acc
return acc + (typeof total === "number" ? total : 0)
}, 0)
})()
const statusData = Object.entries(report?.tickets.byStatus ?? {}).map(([status, value]) => ({
status,
value: typeof value === "number" ? value : 0,
}))
const osData = (report?.machines.byOs ?? []).map((item: { label: string; value: number }, index: number) => ({
...item,
fill: `var(--chart-${(index % 6) + 1})`,
}))
if (!isStaff) {
return (
<Card className="border-slate-200">
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">Somente a equipe interna pode acessar este relatório.</p>
</CardContent>
</Card>
)
}
if (companies && companies.length === 0) {
return (
<Card className="border-slate-200">
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">Nenhuma empresa disponível para gerar relatórios.</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-neutral-900">Visão consolidada por empresa</h2>
<p className="text-sm text-neutral-500">Acompanhe tickets, inventário e colaboradores de um cliente específico.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={selectedCompany} onValueChange={setSelectedCompany} disabled={!companies?.length}>
<SelectTrigger className="w-[220px] rounded-xl border-slate-200">
<SelectValue placeholder="Selecione a empresa" />
</SelectTrigger>
<SelectContent className="rounded-xl">
{(companies ?? []).map((company) => (
<SelectItem key={company.id} value={company.id as string}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={(value) => setTimeRange(value as typeof timeRange)}>
<SelectTrigger className="w-[160px] rounded-xl border-slate-200">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{!report || isLoading ? (
<div className="space-y-6">
<Skeleton className="h-24 w-full rounded-2xl" />
<Skeleton className="h-72 w-full rounded-2xl" />
<div className="grid gap-6 md:grid-cols-2">
<Skeleton className="h-72 w-full rounded-2xl" />
<Skeleton className="h-72 w-full rounded-2xl" />
</div>
</div>
) : (
<>
<div className="grid gap-4 lg:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tickets em aberto</CardTitle>
<CardDescription className="text-neutral-600">Chamados associados a esta empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{openTicketCount}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Máquinas monitoradas</CardTitle>
<CardDescription className="text-neutral-600">Inventário registrado nesta empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.machines.total}</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Colaboradores ativos</CardTitle>
<CardDescription className="text-neutral-600">Usuários vinculados à empresa.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">{report.users.total}</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Abertura x resolução de tickets</CardTitle>
<CardDescription className="text-neutral-600">
Comparativo diário no período selecionado.
</CardDescription>
</div>
</CardHeader>
<CardContent className="px-2 pb-6 sm:px-6">
<ChartContainer config={TICKET_TREND_CONFIG} className="aspect-auto h-[280px] w-full">
<AreaChart data={report.tickets.trend}>
<defs>
<linearGradient id="trendOpened" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-opened)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-opened)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="trendResolved" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-resolved)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-resolved)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString("pt-BR", { day: "2-digit", month: "short" })
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(value) =>
new Date(value).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
})
}
/>
}
/>
<Area dataKey="opened" type="monotone" stroke="var(--color-opened)" fill="url(#trendOpened)" strokeWidth={2} />
<Area dataKey="resolved" type="monotone" stroke="var(--color-resolved)" fill="url(#trendResolved)" strokeWidth={2} />
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-[1.2fr_1fr]">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Distribuição de status dos tickets</CardTitle>
<CardDescription className="text-neutral-600">Status atuais dos chamados da empresa.</CardDescription>
</CardHeader>
<CardContent className="px-2 pb-6 sm:px-6">
<ChartContainer config={{}} className="aspect-auto h-[280px] w-full">
<BarChart data={statusData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="status"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => STATUS_LABELS[value] ?? value}
/>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="value"
labelFormatter={(value) => STATUS_LABELS[value] ?? value}
/>
}
/>
<Bar dataKey="value" fill="var(--chart-3)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Sistemas operacionais</CardTitle>
<CardDescription className="text-neutral-600">Inventário das máquinas desta empresa.</CardDescription>
</CardHeader>
<CardContent className="pb-6">
<ChartContainer config={MACHINE_STATUS_CONFIG} className="mx-auto aspect-square max-h-[240px]">
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie data={osData} dataKey="value" nameKey="label" label />
</PieChart>
</ChartContainer>
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Tickets recentes (máximo 6)</CardTitle>
<CardDescription className="text-neutral-600">
Chamados em aberto para a empresa filtrada.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{openTickets.length === 0 ? (
<p className="rounded-xl border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhum chamado aberto no período selecionado.
</p>
) : (
<ul className="space-y-3">
{openTickets.map((ticket) => (
<li
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-sm"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-neutral-900">
#{ticket.reference} · {ticket.subject}
</p>
<p className="text-xs text-neutral-500">
Atualizado{" "}
{new Date(ticket.updatedAt).toLocaleString("pt-BR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{STATUS_LABELS[ticket.status] ?? ticket.status}
</Badge>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</>
)}
</div>
)
}

View file

@ -82,8 +82,8 @@ export function CsatReport() {
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar CSV
<a href={`/api/reports/csat.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>

View file

@ -193,10 +193,10 @@ export function HoursReport() {
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a
href={`/api/reports/hours-by-client.csv?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
download
>
Exportar CSV
Exportar XLSX
</a>
</Button>
</div>

View file

@ -164,8 +164,8 @@ export function SlaReport() {
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/sla.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar CSV
<a href={`/api/reports/sla.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar XLSX
</a>
</Button>
</div>

View file

@ -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 {

View file

@ -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,

View file

@ -41,6 +41,16 @@ export const ticketCompanySummarySchema = z.object({
})
export type TicketCompanySummary = z.infer<typeof ticketCompanySummarySchema>
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<typeof ticketMachineSummarySchema>
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(),

327
src/lib/xlsx.ts Normal file
View file

@ -0,0 +1,327 @@
import { TextEncoder } from "util"
type WorksheetRow = Array<unknown>
export type WorksheetConfig = {
name: string
headers: string[]
rows: WorksheetRow[]
}
type ZipEntry = {
path: string
data: Buffer
}
const XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.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 `<c r="${ref}"/>`
}
if (value instanceof Date) {
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(value.toISOString())}</t></is></c>`
}
if (typeof value === "number" && Number.isFinite(value)) {
return `<c r="${ref}"><v>${value}</v></c>`
}
if (typeof value === "boolean") {
return `<c r="${ref}"><v>${value ? 1 : 0}</v></c>`
}
let text: string
if (typeof value === "string") {
text = value
} else {
text = JSON.stringify(value)
}
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(text)}</t></is></c>`
}
function buildWorksheetXml(config: WorksheetConfig): string {
const rows: string[] = []
const headerRow = config.headers.map((header, idx) => formatCell(header, idx, 0)).join("")
rows.push(`<row r="1">${headerRow}</row>`)
config.rows.forEach((rowData, rowIdx) => {
const cells = config.headers.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, rowIdx + 1)).join("")
rows.push(`<row r="${rowIdx + 2}">${cells}</row>`)
})
return [
XML_DECLARATION,
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
" <sheetData>",
rows.map((row) => ` ${row}`).join("\n"),
" </sheetData>",
"</worksheet>",
].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(
`<Relationship Id="${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${sheetId}.xml"/>`,
)
return `<sheet name="${escapeXml(sheet.name)}" sheetId="${sheetId}" r:id="${relId}"/>`
})
const workbookXml = [
XML_DECLARATION,
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
" <sheets>",
sheetRefs.map((sheet) => ` ${sheet}`).join("\n"),
" </sheets>",
"</workbook>",
].join("\n")
workbookRels.push(
'<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>',
)
const workbookRelsXml = [
XML_DECLARATION,
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
workbookRels.map((rel) => ` ${rel}`).join("\n"),
"</Relationships>",
].join("\n")
const stylesXml = [
XML_DECLARATION,
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
' <fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>',
' <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>',
' <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>',
' <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>',
' <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>',
' <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>',
"</styleSheet>",
].join("\n")
const corePropsXml = [
XML_DECLARATION,
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">',
" <dc:creator>Raven</dc:creator>",
" <cp:lastModifiedBy>Raven</cp:lastModifiedBy>",
` <dcterms:created xsi:type="dcterms:W3CDTF">${escapeXml(timestamp)}</dcterms:created>`,
` <dcterms:modified xsi:type="dcterms:W3CDTF">${escapeXml(timestamp)}</dcterms:modified>`,
"</cp:coreProperties>",
].join("\n")
const appPropsXml = [
XML_DECLARATION,
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">',
" <Application>Raven</Application>",
" <DocSecurity>0</DocSecurity>",
" <ScaleCrop>false</ScaleCrop>",
" <HeadingPairs>",
' <vt:vector size="2" baseType="variant">',
' <vt:variant><vt:lpstr>Worksheets</vt:lpstr></vt:variant>',
` <vt:variant><vt:i4>${sheets.length}</vt:i4></vt:variant>`,
" </vt:vector>",
" </HeadingPairs>",
" <TitlesOfParts>",
` <vt:vector size="${sheets.length}" baseType="lpstr">`,
sheets.map((sheet) => ` <vt:lpstr>${escapeXml(sheet.name)}</vt:lpstr>`).join("\n"),
" </vt:vector>",
" </TitlesOfParts>",
"</Properties>",
].join("\n")
const contentTypes = [
XML_DECLARATION,
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">',
' <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>',
' <Default Extension="xml" ContentType="application/xml"/>',
' <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>',
' <Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>',
' <Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>',
' <Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>',
...sheets.map(
(_sheet, index) =>
` <Override PartName="/xl/worksheets/sheet${index + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
),
"</Types>",
].join("\n")
const rootRelsXml = [
XML_DECLARATION,
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">',
' <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>',
' <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>',
' <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>',
"</Relationships>",
].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)
}

View file

@ -255,6 +255,8 @@ function buildTimelineMessage(type: string, payload: Record<string, unknown> | 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<string, unknown> | 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) })
}