chore: prep platform improvements
This commit is contained in:
parent
a62f3d5283
commit
c5ddd54a3e
24 changed files with 777 additions and 649 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
400
src/components/admin/slas/category-sla-drawer.tsx
Normal file
400
src/components/admin/slas/category-sla-drawer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
94
src/components/admin/slas/category-sla-manager.tsx
Normal file
94
src/components/admin/slas/category-sla-manager.tsx
Normal 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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,16 @@ function SelectTrigger({
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
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}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue