fix(reports): remove truncation cap in range collectors to avoid dropped records

feat(calendar): migrate to react-day-picker v9 and polish UI
- Update classNames and CSS import (style.css)
- Custom Dropdown via shadcn Select
- Nav arrows aligned with caption (around)
- Today highlight with cyan tone, weekdays in sentence case
- Wider layout to avoid overflow; remove inner wrapper

chore(tickets): make 'Patrimônio do computador (se houver)' optional
- Backend hotfix to enforce optional + label on existing tenants
- Hide required asterisk for this field in portal/new-ticket

refactor(new-ticket): remove channel dropdown from admin/agent flow
- Keep default channel as MANUAL

feat(ux): simplify requester section and enlarge combobox trigger
- Remove RequesterPreview redundancy; show company badge in trigger
This commit is contained in:
codex-bot 2025-11-04 11:51:08 -03:00
parent e0ef66555d
commit a8333c010f
28 changed files with 1752 additions and 455 deletions

View file

@ -1091,14 +1091,19 @@ export const getById = query({
export const listAlerts = query({
args: {
machineId: v.id("machines"),
machineId: v.optional(v.id("machines")),
deviceId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const machineId = args.machineId ?? args.deviceId
if (!machineId) {
throw new ConvexError("Identificador do dispositivo não informado")
}
const limit = Math.max(1, Math.min(args.limit ?? 50, 200))
const alerts = await ctx.db
.query("machineAlerts")
.withIndex("by_machine_created", (q) => q.eq("machineId", args.machineId))
.withIndex("by_machine_created", (q) => q.eq("machineId", machineId))
.order("desc")
.take(limit)
@ -1117,10 +1122,15 @@ export const listAlerts = query({
export const listOpenTickets = query({
args: {
machineId: v.id("machines"),
machineId: v.optional(v.id("machines")),
deviceId: v.optional(v.id("machines")),
limit: v.optional(v.number()),
},
handler: async (ctx, { machineId, limit }) => {
handler: async (ctx, { machineId: providedMachineId, deviceId, limit }) => {
const machineId = providedMachineId ?? deviceId
if (!machineId) {
throw new ConvexError("Identificador do dispositivo não informado")
}
const machine = await ctx.db.get(machineId)
if (!machine) {
return { totalOpen: 0, hasMore: false, tickets: [] }

View file

@ -7,7 +7,7 @@ import { requireStaff } from "./rbac";
import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines";
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
type QueryFilterBuilder = { lt: (field: unknown, value: number) => unknown; field: (name: string) => unknown };
const STATUS_NORMALIZE_MAP: Record<string, TicketStatusNormalized> = {
NEW: "PENDING",
PENDING: "PENDING",
@ -122,34 +122,61 @@ async function fetchScopedTicketsByCreatedRange(
endMs: number,
companyId?: Id<"companies">,
) {
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
const results: Doc<"tickets">[] = [];
const chunkSize = 7 * ONE_DAY_MS;
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
const baseQuery = buildQuery(chunkStart);
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
const queryForChunk =
typeof filterFn === "function"
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("createdAt"), chunkEnd))
: baseQuery;
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
if (typeof collectFn !== "function") {
throw new ConvexError("Indexed query does not support collect (createdAt)");
}
const page = await collectFn.call(queryForChunk);
if (!page || page.length === 0) continue;
for (const ticket of page) {
const createdAt = typeof ticket.createdAt === "number" ? ticket.createdAt : null;
if (createdAt === null) continue;
if (createdAt < chunkStart || createdAt >= endMs) continue;
results.push(ticket);
}
}
return results;
};
if (viewer.role === "MANAGER") {
if (!viewer.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_created", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", startMs),
)
.filter((q) => q.lt(q.field("createdAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_company_created", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("createdAt", chunkStart)
)
);
}
if (companyId) {
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_created", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", startMs),
)
.filter((q) => q.lt(q.field("createdAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_company_created", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("createdAt", chunkStart)
)
);
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", startMs))
.filter((q) => q.lt(q.field("createdAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_created", (q) => q.eq("tenantId", tenantId).gte("createdAt", chunkStart))
);
}
async function fetchScopedTicketsByResolvedRange(
@ -160,34 +187,61 @@ async function fetchScopedTicketsByResolvedRange(
endMs: number,
companyId?: Id<"companies">,
) {
const collectRange = async (buildQuery: (chunkStart: number) => unknown) => {
const results: Doc<"tickets">[] = [];
const chunkSize = 7 * ONE_DAY_MS;
for (let chunkStart = startMs; chunkStart < endMs; chunkStart += chunkSize) {
const chunkEnd = Math.min(chunkStart + chunkSize, endMs);
const baseQuery = buildQuery(chunkStart);
const filterFn = (baseQuery as { filter?: (fn: (q: QueryFilterBuilder) => unknown) => unknown }).filter;
const queryForChunk =
typeof filterFn === "function"
? filterFn.call(baseQuery, (q: QueryFilterBuilder) => q.lt(q.field("resolvedAt"), chunkEnd))
: baseQuery;
const collectFn = (queryForChunk as { collect?: () => Promise<Doc<"tickets">[]> }).collect;
if (typeof collectFn !== "function") {
throw new ConvexError("Indexed query does not support collect (resolvedAt)");
}
const page = await collectFn.call(queryForChunk);
if (!page || page.length === 0) continue;
for (const ticket of page) {
const resolvedAt = typeof ticket.resolvedAt === "number" ? ticket.resolvedAt : null;
if (resolvedAt === null) continue;
if (resolvedAt < chunkStart || resolvedAt >= endMs) continue;
results.push(ticket);
}
}
return results;
};
if (viewer.role === "MANAGER") {
if (!viewer.user.companyId) {
throw new ConvexError("Gestor não possui empresa vinculada");
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", viewer.user.companyId!).gte("resolvedAt", chunkStart)
)
);
}
if (companyId) {
return ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", startMs),
)
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_company_resolved", (q) =>
q.eq("tenantId", tenantId).eq("companyId", companyId).gte("resolvedAt", chunkStart)
)
);
}
return ctx.db
.query("tickets")
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", startMs))
.filter((q) => q.lt(q.field("resolvedAt"), endMs))
.collect();
return collectRange((chunkStart) =>
ctx.db
.query("tickets")
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", tenantId).gte("resolvedAt", chunkStart))
);
}
async function fetchQueues(ctx: QueryCtx, tenantId: string) {

View file

@ -59,6 +59,107 @@ const TICKET_FORM_CONFIG = [
},
];
type TicketFormFieldSeed = {
key: string;
label: string;
type: "text" | "number" | "date" | "select" | "boolean";
required?: boolean;
description?: string;
options?: Array<{ value: string; label: string }>;
};
const TICKET_FORM_DEFAULT_FIELDS: Record<string, TicketFormFieldSeed[]> = {
admissao: [
{ key: "solicitante_nome", label: "Nome do solicitante", type: "text", required: true, description: "Quem está solicitando a admissão." },
{ key: "solicitante_telefone", label: "Telefone do solicitante", type: "text", required: true },
{ key: "solicitante_ramal", label: "Ramal", type: "text" },
{
key: "solicitante_email",
label: "E-mail do solicitante",
type: "text",
required: true,
description: "Informe um e-mail válido para retornarmos atualizações.",
},
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
{ key: "colaborador_email_desejado", label: "E-mail do colaborador", type: "text", required: true, description: "Endereço de e-mail que deverá ser criado." },
{ key: "colaborador_data_nascimento", label: "Data de nascimento", type: "date", required: true },
{ key: "colaborador_rg", label: "RG", type: "text", required: true },
{ key: "colaborador_cpf", label: "CPF", type: "text", required: true },
{ key: "colaborador_data_inicio", label: "Data de início", type: "date", required: true },
{ key: "colaborador_departamento", label: "Departamento", type: "text", required: true },
{
key: "colaborador_nova_contratacao",
label: "O colaborador é uma nova contratação?",
type: "select",
required: true,
description: "Informe se é uma nova contratação ou substituição.",
options: [
{ value: "nova", label: "Sim, nova contratação" },
{ value: "substituicao", label: "Não, irá substituir alguém" },
],
},
{
key: "colaborador_substituicao",
label: "Quem será substituído?",
type: "text",
description: "Preencha somente se for uma substituição.",
},
{
key: "colaborador_grupos_email",
label: "Grupos de e-mail necessários",
type: "text",
required: true,
description: "Liste os grupos ou escreva 'Não se aplica'.",
},
{
key: "colaborador_equipamento",
label: "Equipamento disponível",
type: "text",
required: true,
description: "Informe se já existe equipamento ou qual deverá ser disponibilizado.",
},
{
key: "colaborador_permissoes_pasta",
label: "Permissões de pastas",
type: "text",
required: true,
description: "Indique quais pastas ou qual colaborador servirá de referência.",
},
{
key: "colaborador_observacoes",
label: "Observações adicionais",
type: "text",
required: true,
},
{
key: "colaborador_patrimonio",
label: "Patrimônio do computador (se houver)",
type: "text",
required: false,
},
],
desligamento: [
{ key: "contato_nome", label: "Contato responsável", type: "text", required: true },
{ key: "contato_email", label: "E-mail do contato", type: "text", required: true },
{ key: "contato_telefone", label: "Telefone do contato", type: "text" },
{ key: "colaborador_nome", label: "Nome do colaborador", type: "text", required: true },
{ key: "colaborador_departamento", label: "Departamento do colaborador", type: "text", required: true },
{
key: "colaborador_email",
label: "E-mail do colaborador",
type: "text",
required: true,
description: "Informe o e-mail que deve ser desativado.",
},
{
key: "colaborador_patrimonio",
label: "Patrimônio do computador",
type: "text",
description: "Informe o patrimônio se houver equipamento vinculado.",
},
],
};
function plainTextLength(html: string): number {
try {
const text = String(html)
@ -184,6 +285,62 @@ function resolveFormEnabled(
return baseEnabled
}
async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
const now = Date.now();
for (const template of TICKET_FORM_CONFIG) {
const defaults = TICKET_FORM_DEFAULT_FIELDS[template.key] ?? [];
if (!defaults.length) {
continue;
}
const existing = await ctx.db
.query("ticketFields")
.withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", template.key))
.collect();
// Hotfix: garantir que "Patrimônio do computador (se houver)" seja opcional na admissão
if (template.key === "admissao") {
const patrimonio = existing.find((f) => f.key === "colaborador_patrimonio");
if (patrimonio) {
const shouldBeOptional = false;
const needsRequiredFix = Boolean(patrimonio.required) !== shouldBeOptional;
const desiredLabel = "Patrimônio do computador (se houver)";
const needsLabelFix = (patrimonio.label ?? "").trim() !== desiredLabel;
if (needsRequiredFix || needsLabelFix) {
await ctx.db.patch(patrimonio._id, {
required: shouldBeOptional,
label: desiredLabel,
updatedAt: now,
});
}
}
}
const existingKeys = new Set(existing.map((field) => field.key));
let order = existing.reduce((max, field) => Math.max(max, field.order ?? 0), 0);
for (const field of defaults) {
if (existingKeys.has(field.key)) {
// Campo já existe: não sobrescrevemos personalizações do cliente, exceto hotfix acima
continue;
}
order += 1;
await ctx.db.insert("ticketFields", {
tenantId,
key: field.key,
label: field.label,
description: field.description ?? "",
type: field.type,
required: Boolean(field.required),
options: field.options?.map((option) => ({
value: option.value,
label: option.label,
})),
scope: template.key,
order,
createdAt: now,
updatedAt: now,
});
}
}
}
export function buildAssigneeChangeComment(
reason: string,
context: { previousName: string; nextName: string },
@ -2472,6 +2629,18 @@ export const markChatRead = mutation({
},
})
export const ensureTicketFormDefaults = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { tenantId, actorId }) => {
await requireUser(ctx, actorId, tenantId);
await ensureTicketFormDefaultsForTenant(ctx, tenantId);
return { ok: true };
},
});
export async function submitCsatHandler(
ctx: MutationCtx,
{ ticketId, actorId, score, maxScore, comment }: { ticketId: Id<"tickets">; actorId: Id<"users">; score: number; maxScore?: number | null; comment?: string | null }