chore: prep platform improvements

This commit is contained in:
Esdras Renan 2025-11-09 21:09:38 -03:00
parent a62f3d5283
commit c5ddd54a3e
24 changed files with 777 additions and 649 deletions

View file

@ -412,8 +412,7 @@ export const deleteCategory = mutation({
} }
const ticketsToMove = await ctx.db const ticketsToMove = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.collect() .collect()
for (const ticket of ticketsToMove) { for (const ticket of ticketsToMove) {
await ctx.db.patch(ticket._id, { await ctx.db.patch(ticket._id, {
@ -425,8 +424,7 @@ export const deleteCategory = mutation({
} else { } else {
const ticketsLinked = await ctx.db const ticketsLinked = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_category", (q) => q.eq("tenantId", tenantId).eq("categoryId", categoryId))
.filter((q) => q.eq(q.field("categoryId"), categoryId))
.first() .first()
if (ticketsLinked) { if (ticketsLinked) {
throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino") throw new ConvexError("Não é possível remover uma categoria vinculada a tickets sem informar destino")
@ -526,8 +524,7 @@ export const deleteSubcategory = mutation({
} }
const tickets = await ctx.db const tickets = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.collect() .collect()
for (const ticket of tickets) { for (const ticket of tickets) {
await ctx.db.patch(ticket._id, { await ctx.db.patch(ticket._id, {
@ -538,8 +535,7 @@ export const deleteSubcategory = mutation({
} else { } else {
const linked = await ctx.db const linked = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_subcategory", (q) => q.eq("tenantId", tenantId).eq("subcategoryId", subcategoryId))
.filter((q) => q.eq(q.field("subcategoryId"), subcategoryId))
.first() .first()
if (linked) { if (linked) {
throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino") throw new ConvexError("Não é possível remover uma subcategoria vinculada a tickets sem informar destino")

View file

@ -37,6 +37,15 @@ function validateOptions(type: FieldType, options: { value: string; label: strin
} }
} }
async function validateCompanyScope(ctx: AnyCtx, tenantId: string, companyId?: Id<"companies"> | null) {
if (!companyId) return undefined;
const company = await ctx.db.get(companyId);
if (!company || company.tenantId !== tenantId) {
throw new ConvexError("Empresa inválida para o campo");
}
return companyId;
}
export const list = query({ export const list = query({
args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) }, args: { tenantId: v.string(), viewerId: v.id("users"), scope: v.optional(v.string()) },
handler: async (ctx, { tenantId, viewerId, scope }) => { handler: async (ctx, { tenantId, viewerId, scope }) => {
@ -64,6 +73,7 @@ export const list = query({
options: field.options ?? [], options: field.options ?? [],
order: field.order, order: field.order,
scope: field.scope ?? "all", scope: field.scope ?? "all",
companyId: field.companyId ?? null,
createdAt: field.createdAt, createdAt: field.createdAt,
updatedAt: field.updatedAt, updatedAt: field.updatedAt,
})); }));
@ -97,6 +107,7 @@ export const listForTenant = query({
options: field.options ?? [], options: field.options ?? [],
order: field.order, order: field.order,
scope: field.scope ?? "all", scope: field.scope ?? "all",
companyId: field.companyId ?? null,
})); }));
}, },
}); });
@ -118,8 +129,9 @@ export const create = mutation({
) )
), ),
scope: v.optional(v.string()), scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
}, },
handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope }) => { handler: async (ctx, { tenantId, actorId, label, description, type, required, options, scope, companyId }) => {
await requireAdmin(ctx, actorId, tenantId); await requireAdmin(ctx, actorId, tenantId);
const normalizedLabel = label.trim(); const normalizedLabel = label.trim();
if (normalizedLabel.length < 2) { if (normalizedLabel.length < 2) {
@ -140,6 +152,7 @@ export const create = mutation({
} }
return safe; return safe;
})(); })();
const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined);
const existing = await ctx.db const existing = await ctx.db
.query("ticketFields") .query("ticketFields")
@ -158,6 +171,7 @@ export const create = mutation({
options, options,
order: maxOrder + 1, order: maxOrder + 1,
scope: normalizedScope, scope: normalizedScope,
companyId: companyRef,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
@ -183,8 +197,9 @@ export const update = mutation({
) )
), ),
scope: v.optional(v.string()), scope: v.optional(v.string()),
companyId: v.optional(v.id("companies")),
}, },
handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope }) => { handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options, scope, companyId }) => {
await requireAdmin(ctx, actorId, tenantId); await requireAdmin(ctx, actorId, tenantId);
const field = await ctx.db.get(fieldId); const field = await ctx.db.get(fieldId);
if (!field || field.tenantId !== tenantId) { if (!field || field.tenantId !== tenantId) {
@ -208,6 +223,7 @@ export const update = mutation({
} }
return safe; return safe;
})(); })();
const companyRef = await validateCompanyScope(ctx, tenantId, companyId ?? undefined);
let key = field.key; let key = field.key;
if (field.label !== normalizedLabel) { if (field.label !== normalizedLabel) {
@ -223,6 +239,7 @@ export const update = mutation({
required, required,
options, options,
scope: normalizedScope, scope: normalizedScope,
companyId: companyRef,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}, },

View file

@ -211,8 +211,7 @@ async function ensureQueue(
const byName = await ctx.db const byName = await ctx.db
.query("queues") .query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", data.name))
.filter((q) => q.eq(q.field("name"), data.name))
.first() .first()
if (byName) { if (byName) {
if (byName.slug !== slug) { if (byName.slug !== slug) {

View file

@ -67,8 +67,7 @@ async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, exc
async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) { async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) {
const existing = await ctx.db const existing = await ctx.db
.query("queues") .query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name))
.filter((q) => q.eq(q.field("name"), name))
.first(); .first();
if (existing && (!excludeId || existing._id !== excludeId)) { if (existing && (!excludeId || existing._id !== excludeId)) {
throw new ConvexError("Já existe uma fila com este nome"); throw new ConvexError("Já existe uma fila com este nome");

View file

@ -184,6 +184,8 @@ export default defineSchema({
teamId: v.optional(v.id("teams")), teamId: v.optional(v.id("teams")),
}) })
.index("by_tenant_slug", ["tenantId", "slug"]) .index("by_tenant_slug", ["tenantId", "slug"])
.index("by_tenant_team", ["tenantId", "teamId"])
.index("by_tenant_name", ["tenantId", "name"])
.index("by_tenant", ["tenantId"]), .index("by_tenant", ["tenantId"]),
teams: defineTable({ teams: defineTable({
@ -322,6 +324,9 @@ export default defineSchema({
.index("by_tenant_requester", ["tenantId", "requesterId"]) .index("by_tenant_requester", ["tenantId", "requesterId"])
.index("by_tenant_company", ["tenantId", "companyId"]) .index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant_machine", ["tenantId", "machineId"]) .index("by_tenant_machine", ["tenantId", "machineId"])
.index("by_tenant_category", ["tenantId", "categoryId"])
.index("by_tenant_subcategory", ["tenantId", "subcategoryId"])
.index("by_tenant_sla_policy", ["tenantId", "slaPolicyId"])
.index("by_tenant", ["tenantId"]) .index("by_tenant", ["tenantId"])
.index("by_tenant_created", ["tenantId", "createdAt"]) .index("by_tenant_created", ["tenantId", "createdAt"])
.index("by_tenant_resolved", ["tenantId", "resolvedAt"]) .index("by_tenant_resolved", ["tenantId", "resolvedAt"])
@ -480,6 +485,7 @@ export default defineSchema({
key: v.string(), key: v.string(),
label: v.string(), label: v.string(),
type: v.string(), type: v.string(),
companyId: v.optional(v.id("companies")),
description: v.optional(v.string()), description: v.optional(v.string()),
required: v.boolean(), required: v.boolean(),
order: v.number(), order: v.number(),
@ -498,6 +504,7 @@ export default defineSchema({
.index("by_tenant_key", ["tenantId", "key"]) .index("by_tenant_key", ["tenantId", "key"])
.index("by_tenant_order", ["tenantId", "order"]) .index("by_tenant_order", ["tenantId", "order"])
.index("by_tenant_scope", ["tenantId", "scope"]) .index("by_tenant_scope", ["tenantId", "scope"])
.index("by_tenant_company", ["tenantId", "companyId"])
.index("by_tenant", ["tenantId"]), .index("by_tenant", ["tenantId"]),
ticketFormSettings: defineTable({ ticketFormSettings: defineTable({

View file

@ -126,8 +126,7 @@ export const remove = mutation({
const ticketLinked = await ctx.db const ticketLinked = await ctx.db
.query("tickets") .query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_sla_policy", (q) => q.eq("tenantId", tenantId).eq("slaPolicyId", policyId))
.filter((q) => q.eq(q.field("slaPolicyId"), policyId))
.first(); .first();
if (ticketLinked) { if (ticketLinked) {
throw new ConvexError("Remova a associação de tickets antes de excluir a política"); throw new ConvexError("Remova a associação de tickets antes de excluir a política");

View file

@ -141,8 +141,7 @@ export const remove = mutation({
const queuesLinked = await ctx.db const queuesLinked = await ctx.db
.query("queues") .query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) .withIndex("by_tenant_team", (q) => q.eq("tenantId", tenantId).eq("teamId", teamId))
.filter((q) => q.eq(q.field("teamId"), teamId))
.first(); .first();
if (queuesLinked) { if (queuesLinked) {
throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time"); throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time");

View file

@ -121,6 +121,7 @@ async function cloneFieldsFromTemplate(ctx: MutationCtx, tenantId: string, sourc
required: field.required, required: field.required,
options: field.options ?? undefined, options: field.options ?? undefined,
scope: targetKey, scope: targetKey,
companyId: field.companyId ?? undefined,
order, order,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View file

@ -1,5 +1,7 @@
"use server"; "use server";
import type { Id } from "./_generated/dataModel";
export type TicketFormFieldSeed = { export type TicketFormFieldSeed = {
key: string; key: string;
label: string; label: string;
@ -7,6 +9,7 @@ export type TicketFormFieldSeed = {
required?: boolean; required?: boolean;
description?: string; description?: string;
options?: Array<{ value: string; label: string }>; options?: Array<{ value: string; label: string }>;
companyId?: Id<"companies"> | null;
}; };
export const TICKET_FORM_CONFIG = [ export const TICKET_FORM_CONFIG = [

View file

@ -3,8 +3,7 @@ import { mutation, query } from "./_generated/server";
import { api } from "./_generated/api"; import { api } from "./_generated/api";
import type { MutationCtx, QueryCtx } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server";
import { ConvexError, v } from "convex/values"; import { ConvexError, v } from "convex/values";
import { Id, type Doc, type DataModel } from "./_generated/dataModel"; import { Id, type Doc } from "./_generated/dataModel";
import type { NamedTableInfo, Query as ConvexQuery } from "convex/server";
import { requireAdmin, requireStaff, requireUser } from "./rbac"; import { requireAdmin, requireStaff, requireUser } from "./rbac";
import { import {
@ -477,13 +476,15 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<Te
async function fetchTicketFieldsByScopes( async function fetchTicketFieldsByScopes(
ctx: QueryCtx, ctx: QueryCtx,
tenantId: string, tenantId: string,
scopes: string[] scopes: string[],
companyId: Id<"companies"> | null
): Promise<TicketFieldScopeMap> { ): Promise<TicketFieldScopeMap> {
const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope)))); const uniqueScopes = Array.from(new Set(scopes.filter((scope) => Boolean(scope))));
if (uniqueScopes.length === 0) { if (uniqueScopes.length === 0) {
return new Map(); return new Map();
} }
const scopeSet = new Set(uniqueScopes); const scopeSet = new Set(uniqueScopes);
const companyIdStr = companyId ? String(companyId) : null;
const result: TicketFieldScopeMap = new Map(); const result: TicketFieldScopeMap = new Map();
const allFields = await ctx.db const allFields = await ctx.db
.query("ticketFields") .query("ticketFields")
@ -495,6 +496,10 @@ async function fetchTicketFieldsByScopes(
if (!scopeSet.has(scope)) { if (!scopeSet.has(scope)) {
continue; continue;
} }
const fieldCompanyId = field.companyId ? String(field.companyId) : null;
if (fieldCompanyId && (!companyIdStr || companyIdStr !== fieldCompanyId)) {
continue;
}
const current = result.get(scope); const current = result.get(scope);
if (current) { if (current) {
current.push(field); current.push(field);
@ -634,6 +639,7 @@ async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: str
label: option.label, label: option.label,
})), })),
scope: template.key, scope: template.key,
companyId: field.companyId ?? undefined,
order, order,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@ -1319,9 +1325,6 @@ const MAX_FETCH_LIMIT = 1000;
const FETCH_MULTIPLIER_NO_SEARCH = 3; const FETCH_MULTIPLIER_NO_SEARCH = 3;
const FETCH_MULTIPLIER_WITH_SEARCH = 5; const FETCH_MULTIPLIER_WITH_SEARCH = 5;
type TicketsTableInfo = NamedTableInfo<DataModel, "tickets">;
type TicketsQueryBuilder = ConvexQuery<TicketsTableInfo>;
function clampTicketLimit(limit: number) { function clampTicketLimit(limit: number) {
if (!Number.isFinite(limit)) return DEFAULT_TICKETS_LIST_LIMIT; 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))); return Math.max(MIN_TICKETS_LIST_LIMIT, Math.min(MAX_TICKETS_LIST_LIMIT, Math.floor(limit)));
@ -1371,7 +1374,6 @@ export const list = query({
const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null;
const normalizedPriorityFilter = normalizePriorityFilter(args.priority); const normalizedPriorityFilter = normalizePriorityFilter(args.priority);
const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null; const prioritySet = normalizedPriorityFilter.length > 0 ? new Set(normalizedPriorityFilter) : null;
const primaryPriorityFilter = normalizedPriorityFilter.length === 1 ? normalizedPriorityFilter[0] : null;
const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null; const normalizedChannelFilter = args.channel ? args.channel.toUpperCase() : null;
const searchTerm = args.search?.trim().toLowerCase() ?? null; const searchTerm = args.search?.trim().toLowerCase() ?? null;
@ -1379,80 +1381,43 @@ export const list = query({
const requestedLimit = clampTicketLimit(requestedLimitRaw); const requestedLimit = clampTicketLimit(requestedLimitRaw);
const fetchLimit = computeFetchLimit(requestedLimit, Boolean(searchTerm)); 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));
}
if (primaryPriorityFilter) {
working = working.filter((q) => q.eq(q.field("priority"), primaryPriorityFilter));
}
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">[] = []; let base: Doc<"tickets">[] = [];
if (role === "MANAGER") { if (role === "MANAGER") {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!));
.withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.assigneeId) { } else if (args.assigneeId) {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!));
.withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.requesterId) { } else if (args.requesterId) {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!));
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} else if (args.queueId) { } else if (args.queueId) {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!));
.withIndex("by_tenant_queue", (q) => q.eq("tenantId", args.tenantId).eq("queueId", args.queueId!))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} else if (normalizedStatusFilter) { } else if (normalizedStatusFilter) {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter));
.withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", normalizedStatusFilter))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} else if (role === "COLLABORATOR") { } else if (role === "COLLABORATOR") {
const viewerEmail = user.email.trim().toLowerCase(); const viewerEmail = user.email.trim().toLowerCase();
const directQuery = applyQueryFilters( const directQuery = ctx.db
ctx.db .query("tickets")
.query("tickets") .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId));
.withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", viewerId))
);
const directTickets = await directQuery.order("desc").take(fetchLimit); const directTickets = await directQuery.order("desc").take(fetchLimit);
let combined = directTickets; let combined = directTickets;
if (directTickets.length < fetchLimit) { if (directTickets.length < fetchLimit) {
const fallbackQuery = applyQueryFilters( const fallbackQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
);
const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit); const fallbackRaw = await fallbackQuery.order("desc").take(fetchLimit);
const fallbackMatches = fallbackRaw.filter((ticket) => { const fallbackMatches = fallbackRaw.filter((ticket) => {
const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email; const snapshotEmail = (ticket.requesterSnapshot as { email?: string } | undefined)?.email;
@ -1463,9 +1428,7 @@ export const list = query({
} }
base = combined.slice(0, fetchLimit); base = combined.slice(0, fetchLimit);
} else { } else {
const baseQuery = applyQueryFilters( const baseQuery = ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId));
ctx.db.query("tickets").withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId))
);
base = await baseQuery.order("desc").take(fetchLimit); base = await baseQuery.order("desc").take(fetchLimit);
} }
@ -2829,7 +2792,7 @@ export const listTicketForms = query({
const templates = await fetchTemplateSummaries(ctx, tenantId) const templates = await fetchTemplateSummaries(ctx, tenantId)
const scopes = templates.map((template) => template.key) const scopes = templates.map((template) => template.key)
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes) const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes, viewerCompanyId)
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
const settingsByTemplate = staffOverride const settingsByTemplate = staffOverride

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
@ -22,8 +22,6 @@ import {
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { cn } from "@/lib/utils"
type DeleteState<T extends "category" | "subcategory"> = type DeleteState<T extends "category" | "subcategory"> =
| { type: T; targetId: string; reason: string } | { type: T; targetId: string; reason: string }
@ -38,7 +36,6 @@ export function CategoriesManager() {
const [subcategoryDraft, setSubcategoryDraft] = useState("") const [subcategoryDraft, setSubcategoryDraft] = useState("")
const [subcategoryList, setSubcategoryList] = useState<string[]>([]) const [subcategoryList, setSubcategoryList] = useState<string[]>([])
const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null) const [deleteState, setDeleteState] = useState<DeleteState<"category" | "subcategory">>(null)
const [slaCategory, setSlaCategory] = useState<TicketCategory | null>(null)
const createCategory = useMutation(api.categories.createCategory) const createCategory = useMutation(api.categories.createCategory)
const deleteCategory = useMutation(api.categories.deleteCategory) const deleteCategory = useMutation(api.categories.deleteCategory)
const updateCategory = useMutation(api.categories.updateCategory) const updateCategory = useMutation(api.categories.updateCategory)
@ -315,7 +312,6 @@ export function CategoriesManager() {
onDeleteSubcategory={(subcategoryId) => onDeleteSubcategory={(subcategoryId) =>
setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" }) setDeleteState({ type: "subcategory", targetId: subcategoryId, reason: "" })
} }
onConfigureSla={() => setSlaCategory(category)}
disabled={isDisabled} disabled={isDisabled}
/> />
)) ))
@ -378,12 +374,6 @@ export function CategoriesManager() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<CategorySlaDrawer
category={slaCategory}
tenantId={tenantId}
viewerId={viewerId}
onClose={() => setSlaCategory(null)}
/>
</div> </div>
) )
} }
@ -396,7 +386,6 @@ interface CategoryItemProps {
onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void> onCreateSubcategory: (categoryId: string, payload: { name: string }) => Promise<void>
onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void> onUpdateSubcategory: (subcategory: TicketSubcategory, name: string) => Promise<void>
onDeleteSubcategory: (subcategoryId: string) => void onDeleteSubcategory: (subcategoryId: string) => void
onConfigureSla: () => void
} }
function CategoryItem({ function CategoryItem({
@ -407,7 +396,6 @@ function CategoryItem({
onCreateSubcategory, onCreateSubcategory,
onUpdateSubcategory, onUpdateSubcategory,
onDeleteSubcategory, onDeleteSubcategory,
onConfigureSla,
}: CategoryItemProps) { }: CategoryItemProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [name, setName] = useState(category.name) const [name, setName] = useState(category.name)
@ -461,9 +449,6 @@ function CategoryItem({
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={onConfigureSla} disabled={disabled}>
Configurar SLA
</Button>
<Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}> <Button size="sm" variant="outline" onClick={() => setIsEditing(true)} disabled={disabled}>
Editar Editar
</Button> </Button>
@ -579,349 +564,3 @@ type RuleFormState = {
alertThreshold: number alertThreshold: number
pauseStatuses: string[] pauseStatuses: string[]
} }
const PRIORITY_ROWS = [
{ value: "URGENT", label: "Crítico" },
{ value: "HIGH", label: "Alta" },
{ value: "MEDIUM", label: "Média" },
{ value: "LOW", label: "Baixa" },
{ value: "DEFAULT", label: "Sem prioridade" },
] as const
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
{ value: "calendar", label: "Horas corridas" },
{ value: "business", label: "Horas úteis" },
]
const PAUSE_STATUS_OPTIONS = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
{ value: "PAUSED", label: "Pausado" },
] as const
const DEFAULT_RULE_STATE: RuleFormState = {
responseValue: "",
responseUnit: "hours",
responseMode: "calendar",
solutionValue: "",
solutionUnit: "hours",
solutionMode: "calendar",
alertThreshold: 80,
pauseStatuses: ["PAUSED"],
}
type CategorySlaDrawerProps = {
category: TicketCategory | null
tenantId: string
viewerId: Id<"users"> | null
onClose: () => void
}
function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
const [saving, setSaving] = useState(false)
const drawerOpen = Boolean(category)
const canLoad = Boolean(category && viewerId)
const existing = useQuery(
api.categorySlas.get,
canLoad
? {
tenantId,
viewerId: viewerId as Id<"users">,
categoryId: category!.id as Id<"ticketCategories">,
}
: "skip"
) as { rules: Array<{ priority: string; responseTargetMinutes: number | null; responseMode?: string | null; solutionTargetMinutes: number | null; solutionMode?: string | null; alertThreshold?: number | null; pauseStatuses?: string[] | null }> } | undefined
const saveSla = useMutation(api.categorySlas.save)
useEffect(() => {
if (!existing?.rules) {
setRules(buildDefaultRuleState())
return
}
const next = buildDefaultRuleState()
for (const rule of existing.rules) {
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
next[priority] = convertRuleToForm(rule)
}
setRules(next)
}, [existing, category?.id])
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
setRules((current) => ({
...current,
[priority]: {
...current[priority],
...patch,
},
}))
}
const togglePause = (priority: string, status: string) => {
setRules((current) => {
const selected = new Set(current[priority].pauseStatuses)
if (selected.has(status)) {
selected.delete(status)
} else {
selected.add(status)
}
if (selected.size === 0) {
selected.add("PAUSED")
}
return {
...current,
[priority]: {
...current[priority],
pauseStatuses: Array.from(selected),
},
}
})
}
const handleSave = async () => {
if (!category || !viewerId) return
setSaving(true)
toast.loading("Salvando SLA...", { id: "category-sla" })
try {
const payload = PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return {
priority: row.value,
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
responseMode: form.responseMode,
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
solutionMode: form.solutionMode,
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
pauseStatuses: form.pauseStatuses,
}
})
await saveSla({
tenantId,
actorId: viewerId,
categoryId: category.id as Id<"ticketCategories">,
rules: payload,
})
toast.success("SLA atualizado", { id: "category-sla" })
onClose()
} catch (error) {
console.error(error)
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
} finally {
setSaving(false)
}
}
return (
<Dialog
open={drawerOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurar SLA {category?.name ?? ""}</DialogTitle>
<DialogDescription>
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
segunda a sexta, das 8h às 18h.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return (
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
<p className="text-xs text-neutral-500">
{row.value === "DEFAULT" ? "Aplicado quando o ticket não tem prioridade definida." : "Aplica-se aos tickets desta prioridade."}
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SlaInputGroup
title="Tempo de resposta"
amount={form.responseValue}
unit={form.responseUnit}
mode={form.responseMode}
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
onUnitChange={(value) => handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })}
onModeChange={(value) => handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })}
/>
<SlaInputGroup
title="Tempo de solução"
amount={form.solutionValue}
unit={form.solutionUnit}
mode={form.solutionMode}
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
onUnitChange={(value) => handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })}
onModeChange={(value) => handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={10}
max={95}
step={5}
value={form.alertThreshold}
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
/>
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
<div className="mt-2 flex flex-wrap gap-2">
{PAUSE_STATUS_OPTIONS.map((option) => {
const selected = form.pauseStatuses.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => togglePause(row.value, option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition",
selected ? "border-primary bg-primary text-primary-foreground" : "border-slate-200 bg-white text-neutral-600"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving || !viewerId}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function buildDefaultRuleState() {
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
acc[row.value] = { ...DEFAULT_RULE_STATE }
return acc
}, {})
}
function convertRuleToForm(rule: {
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}): RuleFormState {
const response = minutesToForm(rule.responseTargetMinutes)
const solution = minutesToForm(rule.solutionTargetMinutes)
return {
responseValue: response.amount,
responseUnit: response.unit,
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
solutionValue: solution.amount,
solutionUnit: solution.unit,
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
alertThreshold: Math.round(((rule.alertThreshold ?? 0.8) * 100)),
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
}
}
function minutesToForm(input?: number | null) {
if (!input || input <= 0) {
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
}
for (const option of [...TIME_UNITS].reverse()) {
if (input % option.factor === 0) {
return { amount: String(Math.round(input / option.factor)), unit: option.value }
}
}
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
}
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined
}
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
type SlaInputGroupProps = {
title: string
amount: string
unit: RuleFormState["responseUnit"]
mode: RuleFormState["responseMode"]
onAmountChange: (value: string) => void
onUnitChange: (value: string) => void
onModeChange: (value: string) => void
}
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
return (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="number"
min={0}
step={1}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0"
/>
<Select value={unit} onValueChange={onUnitChange}>
<SelectTrigger>
<SelectValue placeholder="Unidade" />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={onModeChange}>
<SelectTrigger>
<SelectValue placeholder="Tipo de contagem" />
</SelectTrigger>
<SelectContent>
{MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -42,7 +42,7 @@ import {
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
@ -468,67 +468,6 @@ export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAcces
return entries return entries
} }
const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([
"provider",
"tool",
"vendor",
"name",
"identifier",
"code",
"id",
"accessId",
"username",
"user",
"login",
"email",
"account",
"password",
"pass",
"secret",
"pin",
"url",
"link",
"remoteUrl",
"console",
"viewer",
"notes",
"note",
"description",
"obs",
"lastVerifiedAt",
"verifiedAt",
"checkedAt",
"updatedAt",
])
function extractRemoteAccessMetadataEntries(metadata: Record<string, unknown> | null | undefined) {
if (!metadata) return [] as Array<[string, unknown]>
return Object.entries(metadata).filter(([key, value]) => {
if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false
if (value === null || value === undefined) return false
if (typeof value === "string" && value.trim().length === 0) return false
return true
})
}
function formatRemoteAccessMetadataKey(key: string) {
return key
.replace(/[_.-]+/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
}
function formatRemoteAccessMetadataValue(value: unknown): string {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
if (value instanceof Date) return formatAbsoluteDateTime(value)
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined { function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
const stringValue = readString(record, ...keys) const stringValue = readString(record, ...keys)
if (stringValue) return stringValue if (stringValue) return stringValue
@ -3029,7 +2968,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
const [deleteDialog, setDeleteDialog] = useState(false) const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [accessDialog, setAccessDialog] = useState(false) const [accessDialog, setAccessDialog] = useState(false)
const [accessEmail, setAccessEmail] = useState<string>(primaryLinkedUser?.email ?? "") const [accessEmail, setAccessEmail] = useState<string>("")
const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "") const [accessName, setAccessName] = useState<string>(primaryLinkedUser?.name ?? "")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator") const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator")
const [savingAccess, setSavingAccess] = useState(false) const [savingAccess, setSavingAccess] = useState(false)
@ -3091,10 +3030,13 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
// removed copy/export inventory JSON buttons as requested // removed copy/export inventory JSON buttons as requested
useEffect(() => { useEffect(() => {
setAccessEmail(primaryLinkedUser?.email ?? "") setAccessEmail("")
}, [device?.id])
useEffect(() => {
setAccessName(primaryLinkedUser?.name ?? "") setAccessName(primaryLinkedUser?.name ?? "")
setAccessRole(personaRole === "manager" ? "manager" : "collaborator") setAccessRole(personaRole === "manager" ? "manager" : "collaborator")
}, [device?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole]) }, [device?.id, primaryLinkedUser?.name, personaRole])
useEffect(() => { useEffect(() => {
setIsActiveLocal(device?.isActive ?? true) setIsActiveLocal(device?.isActive ?? true)
@ -3711,10 +3653,55 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
<InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} /> <InfoChip key={chip.key} label={chip.label} value={chip.value} icon={chip.icon} tone={chip.tone} />
))} ))}
</div> </div>
<div className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controles do dispositivo</p>
{device.registeredBy ? (
<span className="text-xs font-medium text-slate-500">
Registrada via <span className="text-slate-800">{device.registeredBy}</span>
</span>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
{!isManualMobile ? (
<>
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
onClick={handleResetAgent}
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
</>
) : null}
</div>
</div>
{/* Campos personalizados (posicionado logo após métricas) */} {/* Campos personalizados (posicionado logo após métricas) */}
<div className="space-y-3"> <div className="space-y-3 border-t border-slate-100 pt-5">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4> <h4 className="text-sm font-semibold text-neutral-900">Campos personalizados</h4>
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold"> <Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-semibold">
@ -3816,50 +3803,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null} ) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="space-y-3 border-t border-slate-100 pt-5">
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
{!isManualMobile ? (
<>
<Button
size="sm"
variant="outline"
className="gap-2 border-dashed border-amber-300 text-amber-700 hover:border-amber-400 hover:text-amber-800"
onClick={handleResetAgent}
disabled={isResettingAgent}
>
<RefreshCcw className={cn("size-4", isResettingAgent && "animate-spin")} />
{isResettingAgent ? "Resetando agente..." : "Resetar agente"}
</Button>
<Button
size="sm"
variant={isActiveLocal ? "outline" : "default"}
className={cn(
"gap-2 border-dashed",
!isActiveLocal && "bg-emerald-600 text-white hover:bg-emerald-600/90"
)}
onClick={handleToggleActive}
disabled={togglingActive}
>
{isActiveLocal ? <Power className="size-4" /> : <PlayCircle className="size-4" />}
{isActiveLocal ? (togglingActive ? "Desativando..." : "Desativar") : togglingActive ? "Reativando..." : "Reativar"}
</Button>
</>
) : null}
{device.registeredBy ? (
<span
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"gap-2 border-dashed border-slate-200 bg-background cursor-default select-text text-neutral-700 hover:bg-background hover:text-neutral-700 focus-visible:outline-none"
)}
>
Registrada via {device.registeredBy}
</span>
) : null}
</div>
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Acesso remoto</h4> <h4 className="text-sm font-semibold">Acesso remoto</h4>
@ -3889,7 +3833,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{hasRemoteAccess ? ( {hasRemoteAccess ? (
<div className="space-y-3"> <div className="space-y-3">
{remoteAccessEntries.map((entry) => { {remoteAccessEntries.map((entry) => {
const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata)
const lastVerifiedDate = const lastVerifiedDate =
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt) entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
? new Date(entry.lastVerifiedAt) ? new Date(entry.lastVerifiedAt)
@ -3977,12 +3920,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null} ) : null}
{isRustDesk && (entry.identifier || entry.password) ? ( {isRustDesk && (entry.identifier || entry.password) ? (
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
className="mt-1 inline-flex items-center gap-2 bg-white/80 text-slate-800 hover:bg-white" className="mt-1 inline-flex items-center gap-2 border-[#00d6eb]/60 bg-white text-slate-800 shadow-sm transition-colors hover:border-[#00d6eb] hover:bg-[#00e8ff]/10 hover:text-slate-900 focus-visible:border-[#00d6eb] focus-visible:ring-[#00e8ff]/30"
onClick={() => handleRustDeskConnect(entry)} onClick={() => handleRustDeskConnect(entry)}
> >
<MonitorSmartphone className="size-4" /> Conectar via RustDesk <MonitorSmartphone className="size-4 text-[#009bb1]" /> Conectar via RustDesk
</Button> </Button>
) : null} ) : null}
{entry.notes ? ( {entry.notes ? (
@ -4020,21 +3963,6 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div> </div>
) : null} ) : null}
</div> </div>
{metadataEntries.length ? (
<details className="mt-3 rounded-lg border border-slate-200 bg-white/70 px-3 py-2 text-[11px] text-slate-600">
<summary className="cursor-pointer font-semibold text-slate-700 outline-none transition-colors hover:text-slate-900">
Metadados adicionais
</summary>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{metadataEntries.map(([key, value]) => (
<div key={`${entry.clientId}-${key}`} className="flex items-center justify-between gap-3 rounded-md border border-slate-200 bg-white px-2 py-1 shadow-sm">
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
</div>
))}
</div>
</details>
) : null}
</div> </div>
) )
})} })}
@ -4047,7 +3975,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</div> </div>
</section> </section>
<section className="space-y-2"> <section className="space-y-3 border-t border-slate-100 pt-6">
<h4 className="text-sm font-semibold">Usuários vinculados</h4> <h4 className="text-sm font-semibold">Usuários vinculados</h4>
<div className="space-y-2"> <div className="space-y-2">
{primaryLinkedUser?.email ? ( {primaryLinkedUser?.email ? (
@ -4339,7 +4267,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</Dialog> </Dialog>
{!isManualMobile ? ( {!isManualMobile ? (
<section className="space-y-2"> <section className="space-y-3 border-t border-slate-100 pt-6">
<h4 className="text-sm font-semibold">Sincronização</h4> <h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground"> <div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
@ -4377,7 +4305,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null} ) : null}
{!isManualMobile ? ( {!isManualMobile ? (
<section className="space-y-2"> <section className="space-y-3 border-t border-slate-100 pt-6">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4> <h4 className="text-sm font-semibold">Métricas recentes</h4>
{lastUpdateRelative ? ( {lastUpdateRelative ? (
@ -4391,7 +4319,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
) : null} ) : null}
{!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? ( {!isManualMobile && (hardware || network || (labels && labels.length > 0)) ? (
<section className="space-y-3"> <section className="space-y-4 border-t border-slate-100 pt-6">
<div> <div>
<h4 className="text-sm font-semibold">Inventário</h4> <h4 className="text-sm font-semibold">Inventário</h4>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -4500,7 +4428,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{/* Discos (agente) */} {/* Discos (agente) */}
{disks.length > 0 ? ( {disks.length > 0 ? (
<section className="space-y-2"> <section className="space-y-3 border-t border-slate-100 pt-6">
<h4 className="text-sm font-semibold">Discos e partições</h4> <h4 className="text-sm font-semibold">Discos e partições</h4>
<div className="rounded-md border border-slate-200 bg-slate-50/60"> <div className="rounded-md border border-slate-200 bg-slate-50/60">
<Table> <Table>
@ -4531,7 +4459,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
{/* Inventário estendido por SO */} {/* Inventário estendido por SO */}
{extended ? ( {extended ? (
<section className="space-y-3"> <section className="space-y-4 border-t border-slate-100 pt-6">
<div> <div>
<h4 className="text-sm font-semibold">Inventário estendido</h4> <h4 className="text-sm font-semibold">Inventário estendido</h4>
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p> <p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>

View file

@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type FieldOption = { value: string; label: string } type FieldOption = { value: string; label: string }
@ -30,6 +31,7 @@ type Field = {
options: FieldOption[] options: FieldOption[]
order: number order: number
scope: string scope: string
companyId: string | null
} }
const TYPE_LABELS: Record<Field["type"], string> = { const TYPE_LABELS: Record<Field["type"], string> = {
@ -54,6 +56,11 @@ export function FieldsManager() {
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined ) as Array<{ id: string; key: string; label: string }> | undefined
const companies = useQuery(
api.companies.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; name: string; slug?: string }> | undefined
const scopeOptions = useMemo( const scopeOptions = useMemo(
() => [ () => [
{ value: "all", label: "Todos os formulários" }, { value: "all", label: "Todos os formulários" },
@ -62,6 +69,28 @@ export function FieldsManager() {
[templates] [templates]
) )
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
if (!companies) return []
return companies
.map((company) => ({
value: company.id,
label: company.name,
description: company.slug ?? undefined,
keywords: company.slug ? [company.slug] : [],
}))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [companies])
const companyLabelById = useMemo(() => {
const map = new Map<string, string>()
companyOptions.forEach((option) => map.set(option.value, option.label))
return map
}, [companyOptions])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
return [{ value: "all", label: "Todas as empresas" }, ...companyOptions]
}, [companyOptions])
const templateLabelByKey = useMemo(() => { const templateLabelByKey = useMemo(() => {
const map = new Map<string, string>() const map = new Map<string, string>()
templates?.forEach((tpl) => map.set(tpl.key, tpl.label)) templates?.forEach((tpl) => map.set(tpl.key, tpl.label))
@ -79,9 +108,11 @@ export function FieldsManager() {
const [required, setRequired] = useState(false) const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([]) const [options, setOptions] = useState<FieldOption[]>([])
const [scopeSelection, setScopeSelection] = useState<string>("all") const [scopeSelection, setScopeSelection] = useState<string>("all")
const [companySelection, setCompanySelection] = useState<string>("all")
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null) const [editingField, setEditingField] = useState<Field | null>(null)
const [editingScope, setEditingScope] = useState<string>("all") const [editingScope, setEditingScope] = useState<string>("all")
const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all")
const totals = useMemo(() => { const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 } if (!fields) return { total: 0, required: 0, select: 0 }
@ -99,6 +130,8 @@ export function FieldsManager() {
setRequired(false) setRequired(false)
setOptions([]) setOptions([])
setScopeSelection("all") setScopeSelection("all")
setCompanySelection("all")
setEditingCompanySelection("all")
} }
const normalizeOptions = (source: FieldOption[]) => const normalizeOptions = (source: FieldOption[]) =>
@ -121,6 +154,7 @@ export function FieldsManager() {
} }
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
const companyIdValue = companySelection === "all" ? undefined : (companySelection as Id<"companies">)
setSaving(true) setSaving(true)
toast.loading("Criando campo...", { id: "field" }) toast.loading("Criando campo...", { id: "field" })
try { try {
@ -133,6 +167,7 @@ export function FieldsManager() {
required, required,
options: preparedOptions, options: preparedOptions,
scope: scopeValue, scope: scopeValue,
companyId: companyIdValue,
}) })
toast.success("Campo criado", { id: "field" }) toast.success("Campo criado", { id: "field" })
resetForm() resetForm()
@ -173,6 +208,7 @@ export function FieldsManager() {
setRequired(field.required) setRequired(field.required)
setOptions(field.options) setOptions(field.options)
setEditingScope(field.scope ?? "all") setEditingScope(field.scope ?? "all")
setEditingCompanySelection(field.companyId ?? "all")
} }
const handleUpdate = async () => { const handleUpdate = async () => {
@ -187,6 +223,7 @@ export function FieldsManager() {
} }
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = editingScope === "all" ? undefined : editingScope const scopeValue = editingScope === "all" ? undefined : editingScope
const companyIdValue = editingCompanySelection === "all" ? undefined : (editingCompanySelection as Id<"companies">)
setSaving(true) setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" }) toast.loading("Atualizando campo...", { id: "field-edit" })
try { try {
@ -200,6 +237,7 @@ export function FieldsManager() {
required, required,
options: preparedOptions, options: preparedOptions,
scope: scopeValue, scope: scopeValue,
companyId: companyIdValue,
}) })
toast.success("Campo atualizado", { id: "field-edit" }) toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null) setEditingField(null)
@ -347,6 +385,25 @@ export function FieldsManager() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label>Empresa (opcional)</Label>
<SearchableCombobox
value={companySelection}
onValueChange={(value) => setCompanySelection(value ?? "all")}
options={companyComboboxOptions}
placeholder="Todas as empresas"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Todas as empresas</span>
)
}
/>
<p className="text-xs text-neutral-500">
Selecione uma empresa para tornar este campo exclusivo dela. Sem seleção, o campo aparecerá em todos os tickets.
</p>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@ -443,9 +500,14 @@ export function FieldsManager() {
) : null} ) : null}
</div> </div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription> <CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700"> <div className="flex flex-wrap gap-2">
{scopeLabel} <Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
</Badge> {scopeLabel}
</Badge>
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
{field.companyId ? `Empresa: ${companyLabelById.get(field.companyId) ?? "Específica"}` : "Todas as empresas"}
</Badge>
</div>
{field.description ? ( {field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p> <p className="text-sm text-neutral-600">{field.description}</p>
) : null} ) : null}
@ -554,6 +616,25 @@ export function FieldsManager() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label>Empresa (opcional)</Label>
<SearchableCombobox
value={editingCompanySelection}
onValueChange={(value) => setEditingCompanySelection(value ?? "all")}
options={companyComboboxOptions}
placeholder="Todas as empresas"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Todas as empresas</span>
)
}
/>
<p className="text-xs text-neutral-500">
Defina uma empresa para restringir este campo apenas aos tickets dela.
</p>
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View file

@ -0,0 +1,400 @@
"use client"
import { useEffect, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketCategory } from "@/lib/schemas/category"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PRIORITY_ROWS = [
{ value: "URGENT", label: "Crítico" },
{ value: "HIGH", label: "Alta" },
{ value: "MEDIUM", label: "Média" },
{ value: "LOW", label: "Baixa" },
{ value: "DEFAULT", label: "Sem prioridade" },
] as const
const TIME_UNITS: Array<{ value: RuleFormState["responseUnit"]; label: string; factor: number }> = [
{ value: "minutes", label: "Minutos", factor: 1 },
{ value: "hours", label: "Horas", factor: 60 },
{ value: "days", label: "Dias", factor: 1440 },
]
const MODE_OPTIONS: Array<{ value: RuleFormState["responseMode"]; label: string }> = [
{ value: "calendar", label: "Horas corridas" },
{ value: "business", label: "Horas úteis" },
]
const PAUSE_STATUS_OPTIONS = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Em atendimento" },
{ value: "PAUSED", label: "Pausado" },
] as const
const DEFAULT_RULE_STATE: RuleFormState = {
responseValue: "",
responseUnit: "hours",
responseMode: "calendar",
solutionValue: "",
solutionUnit: "hours",
solutionMode: "calendar",
alertThreshold: 80,
pauseStatuses: ["PAUSED"],
}
type RuleFormState = {
responseValue: string
responseUnit: "minutes" | "hours" | "days"
responseMode: "calendar" | "business"
solutionValue: string
solutionUnit: "minutes" | "hours" | "days"
solutionMode: "calendar" | "business"
alertThreshold: number
pauseStatuses: string[]
}
export type CategorySlaDrawerProps = {
category: TicketCategory | null
tenantId: string
viewerId: Id<"users"> | null
onClose: () => void
}
export function CategorySlaDrawer({ category, tenantId, viewerId, onClose }: CategorySlaDrawerProps) {
const [rules, setRules] = useState<Record<string, RuleFormState>>(() => buildDefaultRuleState())
const [saving, setSaving] = useState(false)
const drawerOpen = Boolean(category)
const canLoad = Boolean(category && viewerId)
const existing = useQuery(
api.categorySlas.get,
canLoad
? {
tenantId,
viewerId: viewerId as Id<"users">,
categoryId: category!.id as Id<"ticketCategories">,
}
: "skip"
) as {
rules: Array<{
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}>
} | undefined
const saveSla = useMutation(api.categorySlas.save)
useEffect(() => {
if (!existing?.rules) {
setRules(buildDefaultRuleState())
return
}
const next = buildDefaultRuleState()
for (const rule of existing.rules) {
const priority = rule.priority?.toUpperCase() ?? "DEFAULT"
next[priority] = convertRuleToForm(rule)
}
setRules(next)
}, [existing, category?.id])
const handleChange = (priority: string, patch: Partial<RuleFormState>) => {
setRules((current) => ({
...current,
[priority]: {
...current[priority],
...patch,
},
}))
}
const togglePause = (priority: string, status: string) => {
setRules((current) => {
const selected = new Set(current[priority].pauseStatuses)
if (selected.has(status)) {
selected.delete(status)
} else {
selected.add(status)
}
if (selected.size === 0) {
selected.add("PAUSED")
}
return {
...current,
[priority]: {
...current[priority],
pauseStatuses: Array.from(selected),
},
}
})
}
const handleSave = async () => {
if (!category || !viewerId) return
setSaving(true)
toast.loading("Salvando SLA...", { id: "category-sla" })
try {
const payload = PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return {
priority: row.value,
responseTargetMinutes: convertToMinutes(form.responseValue, form.responseUnit),
responseMode: form.responseMode,
solutionTargetMinutes: convertToMinutes(form.solutionValue, form.solutionUnit),
solutionMode: form.solutionMode,
alertThreshold: Math.min(Math.max(form.alertThreshold, 5), 95) / 100,
pauseStatuses: form.pauseStatuses,
}
})
await saveSla({
tenantId,
actorId: viewerId,
categoryId: category.id as Id<"ticketCategories">,
rules: payload,
})
toast.success("SLA atualizado", { id: "category-sla" })
onClose()
} catch (error) {
console.error(error)
toast.error("Não foi possível salvar as regras de SLA.", { id: "category-sla" })
} finally {
setSaving(false)
}
}
return (
<Dialog
open={drawerOpen}
onOpenChange={(open) => {
if (!open) {
onClose()
}
}}
>
<DialogContent className="max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Configurar SLA {category?.name ?? ""}</DialogTitle>
<DialogDescription>
Defina metas de resposta e resolução para cada prioridade. Os prazos em horas úteis consideram apenas
segunda a sexta, das 8h às 18h.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{PRIORITY_ROWS.map((row) => {
const form = rules[row.value]
return (
<div key={row.value} className="space-y-3 rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold text-neutral-900">{row.label}</p>
<p className="text-xs text-neutral-500">
{row.value === "DEFAULT"
? "Aplicado quando o ticket não tem prioridade definida."
: "Aplica-se aos tickets desta prioridade."}
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<SlaInputGroup
title="Tempo de resposta"
amount={form.responseValue}
unit={form.responseUnit}
mode={form.responseMode}
onAmountChange={(value) => handleChange(row.value, { responseValue: value })}
onUnitChange={(value) =>
handleChange(row.value, { responseUnit: value as RuleFormState["responseUnit"] })
}
onModeChange={(value) =>
handleChange(row.value, { responseMode: value as RuleFormState["responseMode"] })
}
/>
<SlaInputGroup
title="Tempo de solução"
amount={form.solutionValue}
unit={form.solutionUnit}
mode={form.solutionMode}
onAmountChange={(value) => handleChange(row.value, { solutionValue: value })}
onUnitChange={(value) =>
handleChange(row.value, { solutionUnit: value as RuleFormState["solutionUnit"] })
}
onModeChange={(value) =>
handleChange(row.value, { solutionMode: value as RuleFormState["solutionMode"] })
}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Alertar quando</p>
<div className="mt-2 flex items-center gap-2">
<Input
type="number"
min={10}
max={95}
step={5}
value={form.alertThreshold}
onChange={(event) => handleChange(row.value, { alertThreshold: Number(event.target.value) || 0 })}
/>
<span className="text-xs text-neutral-500">% do tempo for consumido.</span>
</div>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Estados que pausam</p>
<div className="mt-2 flex flex-wrap gap-2">
{PAUSE_STATUS_OPTIONS.map((option) => {
const selected = form.pauseStatuses.includes(option.value)
return (
<button
key={option.value}
type="button"
onClick={() => togglePause(row.value, option.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-semibold transition",
selected
? "border-primary bg-primary text-primary-foreground"
: "border-slate-200 bg-white text-neutral-600"
)}
>
{option.label}
</button>
)
})}
</div>
</div>
</div>
</div>
)
})}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving || !viewerId}>
{saving ? "Salvando..." : "Salvar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function buildDefaultRuleState() {
return PRIORITY_ROWS.reduce<Record<string, RuleFormState>>((acc, row) => {
acc[row.value] = { ...DEFAULT_RULE_STATE }
return acc
}, {})
}
function convertRuleToForm(rule: {
priority: string
responseTargetMinutes: number | null
responseMode?: string | null
solutionTargetMinutes: number | null
solutionMode?: string | null
alertThreshold?: number | null
pauseStatuses?: string[] | null
}): RuleFormState {
const response = minutesToForm(rule.responseTargetMinutes)
const solution = minutesToForm(rule.solutionTargetMinutes)
return {
responseValue: response.amount,
responseUnit: response.unit,
responseMode: (rule.responseMode ?? "calendar") as RuleFormState["responseMode"],
solutionValue: solution.amount,
solutionUnit: solution.unit,
solutionMode: (rule.solutionMode ?? "calendar") as RuleFormState["solutionMode"],
alertThreshold: Math.round((rule.alertThreshold ?? 0.8) * 100),
pauseStatuses: rule.pauseStatuses && rule.pauseStatuses.length > 0 ? rule.pauseStatuses : ["PAUSED"],
}
}
function minutesToForm(input?: number | null) {
if (!input || input <= 0) {
return { amount: "", unit: "hours" as RuleFormState["responseUnit"] }
}
for (const option of [...TIME_UNITS].reverse()) {
if (input % option.factor === 0) {
return { amount: String(Math.round(input / option.factor)), unit: option.value }
}
}
return { amount: String(input), unit: "minutes" as RuleFormState["responseUnit"] }
}
function convertToMinutes(value: string, unit: RuleFormState["responseUnit"]) {
const numeric = Number(value)
if (!Number.isFinite(numeric) || numeric <= 0) {
return undefined
}
const factor = TIME_UNITS.find((item) => item.value === unit)?.factor ?? 1
return Math.round(numeric * factor)
}
type SlaInputGroupProps = {
title: string
amount: string
unit: RuleFormState["responseUnit"]
mode: RuleFormState["responseMode"]
onAmountChange: (value: string) => void
onUnitChange: (value: string) => void
onModeChange: (value: string) => void
}
function SlaInputGroup({ title, amount, unit, mode, onAmountChange, onUnitChange, onModeChange }: SlaInputGroupProps) {
return (
<div className="space-y-2 rounded-xl border border-slate-200 bg-white px-3 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{title}</p>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="number"
min={0}
step={1}
value={amount}
onChange={(event) => onAmountChange(event.target.value)}
placeholder="0"
/>
<Select value={unit} onValueChange={onUnitChange}>
<SelectTrigger>
<SelectValue placeholder="Unidade" />
</SelectTrigger>
<SelectContent>
{TIME_UNITS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Select value={mode} onValueChange={onModeChange}>
<SelectTrigger>
<SelectValue placeholder="Tipo de contagem" />
</SelectTrigger>
<SelectContent>
{MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -0,0 +1,94 @@
"use client"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { TicketCategory } from "@/lib/schemas/category"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import type { Id } from "@/convex/_generated/dataModel"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { CategorySlaDrawer } from "./category-sla-drawer"
export function CategorySlaManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId ? (convexUserId as Id<"users">) : null
const categories = useQuery(api.categories.list, { tenantId }) as TicketCategory[] | undefined
const [selectedCategory, setSelectedCategory] = useState<TicketCategory | null>(null)
const sortedCategories = useMemo(() => {
if (!categories) return []
return [...categories].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
}, [categories])
return (
<>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">SLA por categoria</CardTitle>
<CardDescription>
Ajuste metas específicas por prioridade para cada categoria. Útil quando determinados temas exigem prazos
diferentes das políticas gerais.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{categories === undefined ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, index) => (
<Skeleton key={`category-sla-skeleton-${index}`} className="h-16 rounded-xl" />
))}
</div>
) : sortedCategories.length === 0 ? (
<p className="text-sm text-neutral-600">
Cadastre categorias em <strong>Admin Campos personalizados</strong> para liberar esta configuração.
</p>
) : (
<div className="space-y-3">
{sortedCategories.map((category) => (
<div
key={category.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3"
>
<div className="space-y-1">
<p className="text-sm font-semibold text-neutral-900">{category.name}</p>
{category.description ? (
<p className="text-xs text-neutral-500">{category.description}</p>
) : null}
{category.secondary.length ? (
<Badge variant="outline" className="rounded-full border-slate-200 px-3 py-1 text-xs font-semibold">
{category.secondary.length} subcategorias
</Badge>
) : null}
</div>
<Button
size="sm"
variant="secondary"
onClick={() => setSelectedCategory(category)}
className="shrink-0"
disabled={!viewerId}
>
Configurar SLA
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<CategorySlaDrawer
category={selectedCategory}
tenantId={tenantId}
viewerId={viewerId}
onClose={() => setSelectedCategory(null)}
/>
</>
)
}

View file

@ -15,6 +15,8 @@ import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { CategorySlaManager } from "./category-sla-manager"
type SlaPolicy = { type SlaPolicy = {
id: string id: string
name: string name: string
@ -327,6 +329,8 @@ export function SlasManager() {
)} )}
</div> </div>
<CategorySlaManager />
<Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}> <Dialog open={Boolean(editingSla)} onOpenChange={(value) => (!value ? setEditingSla(null) : null)}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>

View file

@ -1043,7 +1043,7 @@ function AccountsTable({
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<p className="text-xs text-neutral-500">Nenhum formulário configurado.</p> <p className="text-xs text-neutral-500">Nenhum formulário configurado.</p>
) : ( ) : (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2">
{templates.map((template) => ( {templates.map((template) => (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground"> <label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox <Checkbox

View file

@ -92,7 +92,7 @@ const navigation: NavigationGroup[] = [
{ title: "SLA & Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" }, { title: "SLA & Produtividade", url: "/reports/sla", icon: TrendingUp, requiredRole: "staff" },
{ title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" }, { title: "Qualidade (CSAT)", url: "/reports/csat", icon: LifeBuoy, requiredRole: "staff" },
{ title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" }, { title: "Backlog", url: "/reports/backlog", icon: BarChart3, requiredRole: "staff" },
{ title: "Empresas", url: "/reports/company", icon: Building2, requiredRole: "staff" }, { title: "Clientes atendidos", url: "/reports/company", icon: Building2, requiredRole: "staff" },
{ title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" }, { title: "Categorias", url: "/reports/categories", icon: Layers3, requiredRole: "staff" },
{ title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" }, { title: "Horas", url: "/reports/hours", icon: Clock4, requiredRole: "staff" },
], ],
@ -111,7 +111,7 @@ const navigation: NavigationGroup[] = [
{ title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" }, { title: "Filas", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin", hidden: true }, { title: "Times & papéis", url: "/admin/teams", icon: UserCog, requiredRole: "admin", hidden: true },
{ {
title: "Empresas", title: "Empresas & clientes",
url: "/admin/companies", url: "/admin/companies",
icon: Building, icon: Building,
requiredRole: "admin", requiredRole: "admin",

View file

@ -11,7 +11,6 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Input } from "@/components/ui/input"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter" import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts" import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
@ -43,7 +42,6 @@ const topClientsChartConfig = {
export function HoursReport() { export function HoursReport() {
const [timeRange, setTimeRange] = useState("90d") const [timeRange, setTimeRange] = useState("90d")
const [query, setQuery] = useState("")
const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId, isStaff } = useAuth() const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -74,12 +72,11 @@ export function HoursReport() {
}, [companies]) }, [companies])
const filtered = useMemo(() => { const filtered = useMemo(() => {
const items = data?.items ?? [] const items = data?.items ?? []
const q = query.trim().toLowerCase() if (companyId !== "all") {
let list = items return items.filter((it) => String(it.companyId) === companyId)
if (companyId !== "all") list = list.filter((it) => String(it.companyId) === companyId) }
if (q) list = list.filter((it) => it.name.toLowerCase().includes(q)) return items
return list }, [data?.items, companyId])
}, [data?.items, query, companyId])
const totals = useMemo(() => { const totals = useMemo(() => {
return filtered.reduce( return filtered.reduce(
@ -185,33 +182,37 @@ export function HoursReport() {
<CardTitle>Horas</CardTitle> <CardTitle>Horas</CardTitle>
<CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription> <CardDescription>Visualize o esforço interno e externo por empresa e acompanhe o consumo contratado.</CardDescription>
<CardAction> <CardAction>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end"> <div className="space-y-4">
<Input <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
placeholder="Pesquisar empresa..." <SearchableCombobox
value={query} value={companyId}
onChange={(e) => setQuery(e.target.value)} onValueChange={(next) => setCompanyId(next ?? "all")}
className="h-9 w-full min-w-56 sm:w-72" options={companyOptions}
/> placeholder="Todas as empresas"
<SearchableCombobox className="w-full min-w-56 lg:w-72"
value={companyId} />
onValueChange={(next) => setCompanyId(next ?? "all")} <div className="flex flex-wrap items-center gap-2">
options={companyOptions} {["90d", "30d", "7d"].map((range) => (
placeholder="Todas as empresas" <Button
className="w-full min-w-56 sm:w-64" key={range}
/> type="button"
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex"> size="sm"
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem> variant={timeRange === range ? "default" : "outline"}
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem> onClick={() => setTimeRange(range)}
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem> >
</ToggleGroup> {range === "90d" ? "90 dias" : range === "30d" ? "30 dias" : "7 dias"}
<Button asChild size="sm" variant="outline"> </Button>
<a ))}
href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} <Button asChild size="sm" variant="outline" className="gap-2">
download <a
> href={`/api/reports/hours-by-client.xlsx?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
Exportar XLSX download
</a> >
</Button> Exportar XLSX
</a>
</Button>
</div>
</div>
</div> </div>
</CardAction> </CardAction>
</CardHeader> </CardHeader>

View file

@ -249,16 +249,6 @@ export function CloseTicketDialog({
} }
}, []) }, [])
useEffect(() => {
if (!open) return
if (templates.length > 0 && !selectedTemplateId && !message) {
const first = templates[0]
const hydrated = hydrateTemplateBody(first.body)
setSelectedTemplateId(first.id)
setMessage(hydrated)
}
}, [open, templates, selectedTemplateId, message, hydrateTemplateBody])
useEffect(() => { useEffect(() => {
if (!open || !enableAdjustment || !shouldAdjustTime) return if (!open || !enableAdjustment || !shouldAdjustTime) return
const internal = splitDuration(workSummary?.internalWorkedMs ?? 0) const internal = splitDuration(workSummary?.internalWorkedMs ?? 0)

View file

@ -184,9 +184,17 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
}) })
}, [convexUserId, ensureTicketFormDefaultsMutation]) }, [convexUserId, ensureTicketFormDefaultsMutation])
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
const formsRemote = useQuery( const formsRemote = useQuery(
api.tickets.listTicketForms, api.tickets.listTicketForms,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip" convexUserId
? {
tenantId: DEFAULT_TENANT_ID,
viewerId: convexUserId as Id<"users">,
companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined,
}
: "skip"
) as TicketFormDefinition[] | undefined ) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => { const forms = useMemo<TicketFormDefinition[]>(() => {
@ -256,7 +264,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const queueValue = form.watch("queueName") ?? "NONE" const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE" const assigneeSelectValue = assigneeValue ?? "NONE"
const companyValue = form.watch("companyId") ?? NO_COMPANY_VALUE
const requesterValue = form.watch("requesterId") ?? "" const requesterValue = form.watch("requesterId") ?? ""
const categoryIdValue = form.watch("categoryId") const categoryIdValue = form.watch("categoryId")
const subcategoryIdValue = form.watch("subcategoryId") const subcategoryIdValue = form.watch("subcategoryId")

View file

@ -93,7 +93,7 @@ export function RecentTicketsPanel() {
const assigned = all const assigned = all
.filter((t) => !!t.assignee) .filter((t) => !!t.assignee)
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()) .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
return [...unassigned, ...assigned].slice(0, 6) return [...unassigned, ...assigned].slice(0, 3)
}, [ticketsResult]) }, [ticketsResult])
useEffect(() => { useEffect(() => {
@ -131,7 +131,7 @@ export function RecentTicketsPanel() {
<CardTitle className="text-lg font-semibold text-neutral-900">Últimos chamados</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Últimos chamados</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4"> <div key={index} className="rounded-xl border border-slate-100 bg-slate-50/60 p-4">
<Skeleton className="mb-2 h-4 w-48" /> <Skeleton className="mb-2 h-4 w-48" />
<Skeleton className="h-3 w-64" /> <Skeleton className="h-3 w-64" />

View file

@ -221,6 +221,7 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
const viewerId = convexUserId as Id<"users"> | null const viewerId = convexUserId as Id<"users"> | null
const tenantId = ticket.tenantId const tenantId = ticket.tenantId
const ticketCompanyId = ticket.company?.id ?? null
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults) const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
@ -247,7 +248,7 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
const formsRemote = useQuery( const formsRemote = useQuery(
api.tickets.listTicketForms, api.tickets.listTicketForms,
canEdit && viewerId canEdit && viewerId
? { tenantId, viewerId } ? { tenantId, viewerId, companyId: ticketCompanyId ? (ticketCompanyId as Id<"companies">) : undefined }
: "skip" : "skip"
) as TicketFormDefinition[] | undefined ) as TicketFormDefinition[] | undefined

View file

@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]", "data-[placeholder]:text-neutral-400 [&_svg:not([class*='text-'])]:text-neutral-500 aria-invalid:border-red-500/80 aria-invalid:ring-red-500/20 flex w-full items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus-visible:ring-[3px] focus-visible:ring-[#00e8ff]/20 focus-visible:border-[#00d6eb] data-[state=open]:border-[#00d6eb] data-[state=open]:shadow-[0_0_0_3px_rgba(0,232,255,0.12)]",
className className
)} )}
{...props} {...props}