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

@ -1,22 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

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 }

View file

@ -1,6 +1,7 @@
# Alterações — 03/11/2025
## Concluído
- [x] Calendário com dropdown de mês/ano redesenhado (admin e portal) para replicar o visual da referência shadcn, com navegação compacta e sombra suave.
- [x] Estruturado backend para `Dispositivos`: novos campos no Convex (`deviceType`, `deviceProfile`, custom fields), mutations (`saveDeviceProfile`, `saveDeviceCustomFields`) e tabelas auxiliares (`deviceFields`, `deviceExportTemplates`).
- [x] Refatorado gerador de inventário XLSX para suportar seleção dinâmica de colunas, campos personalizados e nomenclatura de dispositivos.
- [x] Renomeado "Máquinas" → "Dispositivos" em toda a navegação, rotas, botões (incluindo destaque superior) e mensagens de erro.
@ -15,7 +16,15 @@
- [x] Reatribuição de chamado sem motivo obrigatório; comentário interno só é criado quando o motivo é preenchido.
- [x] Botão “Novo dispositivo” reutiliza o mesmo primário padrão do shadcn usado em “Nova empresa”, mantendo a identidade visual.
- [x] Cartão de CSAT respeita a role normalizada (inclusive em sessões de dispositivos), só aparece para a equipe após o início do atendimento e mostra aviso quando ainda não há avaliações.
- [x] Dashboard de abertos x resolvidos usa buscas indexadas por data, evitando timeouts no Convex.
- [x] Dashboard de abertos x resolvidos usa buscas indexadas por data e paginação semanal (
sem `collect` massivo), evitando timeouts no Convex.
- [x] Filtro por tipo de dispositivo (desktop/celular/tablet) na listagem administrativa com exportação alinhada.
- [x] Consulta de alertas/tickets de dispositivos aceita `deviceId` além de `machineId`, eliminando falhas no painel.
- [x] Busca por ticket relacionado no encerramento reaproveita a lista de sugestões (Enter seleciona o primeiro resultado e o campo exibe `#referência`).
- [x] Portal (cliente e desktop) exibe os badges de status/prioridade em sentence case, alinhando com o padrão do painel web.
- [x] Filtros de empresa nos relatórios/dashboards (Backlog, SLA, Horas, alertas e gráficos) usam combobox pesquisável, facilitando encontrar clientes.
- [x] Campos adicionais de admissão/desligamento organizados em grid responsivo de duas colunas (admin e portal), mantendo booleanos/textareas em largura total.
- [x] Templates de admissão e desligamento com campos dinâmicos habilitados no painel e no portal/desktop, incluindo garantia automática dos campos padrão via `ensureTicketFormDefaults`.
## Riscos
- Necessário validar migração dos dados existentes (máquinas → dispositivos) antes de entrar em produção.

View file

@ -61,6 +61,7 @@
"pdfkit": "^0.17.2",
"postcss": "^8.5.6",
"react": "19.2.0",
"react-day-picker": "^9.4.2",
"react-dom": "19.2.0",
"react-hook-form": "^7.64.0",
"recharts": "^2.15.4",

26
pnpm-lock.yaml generated
View file

@ -137,6 +137,9 @@ importers:
react:
specifier: 19.2.0
version: 19.2.0
react-day-picker:
specifier: ^9.4.2
version: 9.11.1(react@19.2.0)
react-dom:
specifier: 19.2.0
version: 19.2.0(react@19.2.0)
@ -441,6 +444,9 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
@ -3020,6 +3026,9 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@ -4299,6 +4308,12 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-day-picker@9.11.1:
resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
react-dom@19.2.0:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
@ -5293,6 +5308,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
'@date-fns/tz@1.4.1': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@dnd-kit/accessibility@3.1.1(react@19.2.0)':
@ -7884,6 +7901,8 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debug@3.2.7:
@ -9377,6 +9396,13 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-day-picker@9.11.1(react@19.2.0):
dependencies:
'@date-fns/tz': 1.4.1
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
react: 19.2.0
react-dom@19.2.0(react@19.2.0):
dependencies:
react: 19.2.0

View file

@ -231,6 +231,15 @@
font-weight: 400;
}
/* Calendar dropdowns */
.rdp-caption_dropdowns select {
@apply h-8 rounded-lg border border-slate-300 bg-white px-2 text-sm font-medium text-neutral-800 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00e8ff]/20;
}
.rdp-caption_dropdowns select:disabled {
@apply opacity-60;
}
@keyframes recent-ticket-enter {
0% {
opacity: 0;

View file

@ -11,6 +11,7 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
export function AdminAlertsManager() {
const [companyId, setCompanyId] = useState<string>("all")
@ -47,6 +48,21 @@ export function AdminAlertsManager() {
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
return (
<Card className="border-slate-200">
<CardHeader>
@ -56,17 +72,13 @@ export function AdminAlertsManager() {
</CardDescription>
<CardAction>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-2">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full min-w-56 sm:w-64">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-64"
/>
<Select value={range} onValueChange={setRange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Período" />

View file

@ -935,6 +935,13 @@ const DEVICE_TYPE_LABELS: Record<string, string> = {
tablet: "Tablet",
}
const DEVICE_TYPE_FILTER_OPTIONS = [
{ value: "all", label: "Todos os tipos" },
{ value: "desktop", label: DEVICE_TYPE_LABELS.desktop },
{ value: "mobile", label: DEVICE_TYPE_LABELS.mobile },
{ value: "tablet", label: DEVICE_TYPE_LABELS.tablet },
]
function formatDeviceTypeLabel(value?: string | null): string {
if (!value) return "Desconhecido"
const normalized = value.toLowerCase()
@ -1257,6 +1264,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
const { devices, isLoading } = useDevicesQuery(tenantId)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [deviceTypeFilter, setDeviceTypeFilter] = useState<string>("all")
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
const [companySearch, setCompanySearch] = useState<string>("")
const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false)
@ -1595,6 +1603,10 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
const s = resolveDeviceStatus(m).toLowerCase()
if (s !== statusFilter) return false
}
if (deviceTypeFilter !== "all") {
const type = (m.deviceType ?? "desktop").toLowerCase()
if (type !== deviceTypeFilter) return false
}
if (companyFilterSlug !== "all" && (m.companySlug ?? "") !== companyFilterSlug) return false
if (!text) return true
const hay = [
@ -1607,7 +1619,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
.toLowerCase()
return hay.includes(text)
})
}, [devices, q, statusFilter, companyFilterSlug, onlyAlerts])
}, [devices, q, statusFilter, companyFilterSlug, onlyAlerts, deviceTypeFilter])
const handleOpenExportDialog = useCallback(() => {
if (filteredDevices.length === 0) {
toast.info("Não há dispositivos para exportar com os filtros atuais.")
@ -1771,10 +1783,7 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={handleOpenCreateDevice}
>
<Button size="sm" onClick={handleOpenCreateDevice}>
<Plus className="size-4" />
Novo dispositivo
</Button>
@ -1797,6 +1806,18 @@ export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Select value={deviceTypeFilter} onValueChange={setDeviceTypeFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Tipo de dispositivo" />
</SelectTrigger>
<SelectContent>
{DEVICE_TYPE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={isCompanyPopoverOpen} onOpenChange={setIsCompanyPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="min-w-56 justify-between">
@ -2392,12 +2413,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated"
const alertsHistory = useQuery(
api.devices.listAlerts,
device ? { deviceId: device.id as Id<"machines">, limit: 50 } : "skip"
device ? { machineId: device.id as Id<"machines">, limit: 50 } : "skip"
) as DeviceAlertEntry[] | undefined
const deviceAlertsHistory = alertsHistory ?? []
const openTickets = useQuery(
api.devices.listOpenTickets,
device ? { deviceId: device.id as Id<"machines">, limit: 6 } : "skip"
device ? { machineId: device.id as Id<"machines">, limit: 6 } : "skip"
) as DeviceOpenTicketsSummary | undefined
const deviceTickets = openTickets?.tickets ?? []
const totalOpenTickets = openTickets?.totalOpen ?? deviceTickets.length

View file

@ -33,12 +33,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
export const description = "Distribuição semanal de tickets por canal"
@ -48,7 +48,6 @@ export function ChartAreaInteractive() {
const [timeRange, setTimeRange] = React.useState("7d")
// Persistir seleção de empresa globalmente
const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [companyQuery, setCompanyQuery] = React.useState("")
const { session, convexUserId, isStaff } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -73,11 +72,20 @@ export function ChartAreaInteractive() {
api.companies.list,
reportsEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const filteredCompanies = React.useMemo(() => {
const q = companyQuery.trim().toLowerCase()
if (!q) return companies ?? []
return (companies ?? []).filter((c) => c.name.toLowerCase().includes(q))
}, [companies, companyQuery])
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const channels = React.useMemo(() => report?.channels ?? [], [report])
@ -138,25 +146,13 @@ export function ChartAreaInteractive() {
<CardAction>
<div className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
{/* Company picker with search */}
<Select value={companyId} onValueChange={(v) => { setCompanyId(v); }}>
<SelectTrigger className="w-full min-w-56 sm:w-64">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<div className="p-2">
<Input
placeholder="Pesquisar empresa..."
value={companyQuery}
onChange={(e) => setCompanyQuery(e.target.value)}
className="h-8"
/>
</div>
<SelectItem value="all">Todas as empresas</SelectItem>
{filteredCompanies.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-64"
/>
{/* Desktop time range toggles */}
<ToggleGroup

View file

@ -23,9 +23,9 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Skeleton } from "@/components/ui/skeleton"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type PriorityKey = "LOW" | "MEDIUM" | "HIGH" | "URGENT"
@ -63,6 +63,21 @@ export function ChartOpenByPriority() {
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!report) {
return <Skeleton className="h-[300px] w-full" />
}
@ -88,19 +103,13 @@ export function ChartOpenByPriority() {
<CardDescription>Distribuição de tickets iniciados e ainda abertos</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-48 md:w-48"
/>
<ToggleGroup
type="single"
value={timeRange}

View file

@ -10,11 +10,11 @@ import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { formatDateDM, formatDateDMY } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type SeriesPoint = { date: string; opened: number; resolved: number }
@ -47,6 +47,21 @@ export function ChartOpenedResolved() {
reportsEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!data) {
return <Skeleton className="h-[300px] w-full" />
}
@ -60,17 +75,13 @@ export function ChartOpenedResolved() {
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
<ToggleGroup
type="single"
value={timeRange}

View file

@ -13,6 +13,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction }
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
export function ViewsCharts() {
return (
@ -39,6 +40,21 @@ function BacklogPriorityPie() {
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!data) return <Skeleton className="h-[300px] w-full" />
const PRIORITY_LABELS: Record<string, string> = { LOW: "Baixa", MEDIUM: "Média", HIGH: "Alta", URGENT: "Crítica" }
const keys = ["LOW", "MEDIUM", "HIGH", "URGENT"]
@ -60,17 +76,13 @@ function BacklogPriorityPie() {
<CardDescription>Distribuição de tickets no período</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Período" />
@ -121,6 +133,20 @@ function QueuesOpenBar() {
api.companies.list,
isStaff && convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = React.useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
if (!data) return <Skeleton className="h-[300px] w-full" />
const chartData = (data.queueBreakdown ?? []).map((q) => ({ queue: q.name, open: q.open }))
@ -132,17 +158,13 @@ function QueuesOpenBar() {
<CardTitle>Filas com maior volume aberto</CardTitle>
<CardDescription>Distribuição atual por fila</CardDescription>
<CardAction>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
</CardAction>
</CardHeader>
<CardContent>

View file

@ -53,16 +53,16 @@ export function PortalTicketCard({ ticket }: PortalTicketCardProps) {
<p className="mt-1 line-clamp-2 text-sm text-neutral-600">{ticket.summary}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", getTicketStatusBadgeClass(ticket.status))}>
{getTicketStatusLabel(ticket.status)}
<div className="flex flex-col items-end gap-2 text-right">
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", getTicketStatusBadgeClass(ticket.status))}>
{getTicketStatusLabel(ticket.status)}
</Badge>
{!isCustomer ? (
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
{!isCustomer ? (
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold uppercase", priorityTone[ticket.priority])}>
{priorityLabel[ticket.priority]}
</Badge>
) : null}
</div>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 text-sm text-neutral-600">
{!isCustomer ? (

View file

@ -296,9 +296,9 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
) : null}
</div>
<div className="flex flex-col items-end gap-2 text-sm">
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold uppercase" />
<TicketStatusBadge status={ticket.status} className="px-3 py-1 text-xs font-semibold" />
{!isCustomer ? (
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold uppercase ${priorityTone[ticket.priority]}`}>
<Badge className={`rounded-full px-3 py-1 text-xs font-semibold ${priorityTone[ticket.priority]}`}>
{priorityLabel[ticket.priority]}
</Badge>
) : null}
@ -693,4 +693,3 @@ function PortalCommentAttachmentCard({

View file

@ -1,8 +1,10 @@
"use client"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { format, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useRouter } from "next/navigation"
import { useMutation } from "convex/react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -16,6 +18,13 @@ import { Button } from "@/components/ui/button"
import { CategorySelectFields } from "@/components/tickets/category-select"
import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { normalizeCustomFieldInputs, hasMissingRequiredCustomFields } from "@/lib/ticket-form-helpers"
import { cn } from "@/lib/utils"
import type { TicketFormDefinition } from "@/lib/ticket-form-types"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Calendar as CalendarIcon } from "lucide-react"
const DEFAULT_PRIORITY: TicketPriority = "MEDIUM"
@ -34,10 +43,79 @@ export function PortalTicketForm() {
const { convexUserId, session, machineContext, machineContextError, machineContextLoading } = useAuth()
const createTicket = useMutation(api.tickets.create)
const addComment = useMutation(api.tickets.addComment)
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = (convexUserId ?? machineContext?.assignedUserId ?? null) as Id<"users"> | null
const formsRemote = useQuery(
api.tickets.listTicketForms,
viewerId ? { tenantId, viewerId } : "skip"
) as TicketFormDefinition[] | undefined
const forms = useMemo<TicketFormDefinition[]>(() => {
const base: TicketFormDefinition = {
key: "default",
label: "Chamado padrão",
description: "Formulário básico para solicitações gerais.",
fields: [],
}
if (Array.isArray(formsRemote) && formsRemote.length > 0) {
return [base, ...formsRemote]
}
return [base]
}, [formsRemote])
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const hasEnsuredFormsRef = useRef(false)
useEffect(() => {
if (!viewerId || hasEnsuredFormsRef.current) return
hasEnsuredFormsRef.current = true
ensureTicketFormDefaults({
tenantId,
actorId: viewerId,
}).catch((error) => {
console.error("Falha ao preparar formulários personalizados", error)
hasEnsuredFormsRef.current = false
})
}, [viewerId, tenantId, ensureTicketFormDefaults])
useEffect(() => {
if (!forms.length) return
if (!forms.find((form) => form.key === selectedFormKey)) {
setSelectedFormKey(forms[0].key)
setCustomFieldValues({})
}
}, [forms, selectedFormKey])
const selectedForm = useMemo(
() => forms.find((form) => form.key === selectedFormKey) ?? forms[0],
[forms, selectedFormKey]
)
const customFieldsInvalid = useMemo(
() =>
selectedFormKey !== "default" && selectedForm?.fields?.length
? hasMissingRequiredCustomFields(selectedForm.fields, customFieldValues)
: false,
[selectedFormKey, selectedForm, customFieldValues]
)
const handleFormSelection = (value: string) => {
setSelectedFormKey(value)
setCustomFieldValues({})
}
const handleCustomFieldChange = (fieldId: string, value: unknown) => {
setCustomFieldValues((prev) => ({
...prev,
[fieldId]: value,
}))
}
const [subject, setSubject] = useState("")
const [summary, setSummary] = useState("")
const [description, setDescription] = useState("")
@ -48,11 +126,18 @@ export function PortalTicketForm() {
() => attachments.reduce((acc, item) => acc + (item.size ?? 0), 0),
[attachments]
)
const allowTicketMentions = true
const [isSubmitting, setIsSubmitting] = useState(false)
const machineInactive = machineContext?.isActive === false
const isFormValid = useMemo(() => {
return Boolean(subject.trim() && description.trim() && categoryId && subcategoryId && !machineInactive)
}, [subject, description, categoryId, subcategoryId, machineInactive])
if (!subject.trim() || !description.trim() || !categoryId || !subcategoryId || machineInactive) {
return false
}
if (customFieldsInvalid) {
return false
}
return true
}, [subject, description, categoryId, subcategoryId, machineInactive, customFieldsInvalid])
const isViewerReady = Boolean(viewerId)
const viewerErrorMessage = useMemo(() => {
if (!machineContextError) return null
@ -85,6 +170,16 @@ export function PortalTicketForm() {
return
}
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) {
toast.error(normalized.message, { id: "portal-new-ticket" })
return
}
customFieldsPayload = normalized.payload
}
setIsSubmitting(true)
toast.loading("Abrindo chamado...", { id: "portal-new-ticket" })
try {
@ -93,14 +188,16 @@ export function PortalTicketForm() {
tenantId,
subject: trimmedSubject,
summary: trimmedSummary || undefined,
priority: DEFAULT_PRIORITY,
channel: "MANUAL",
queueId: undefined,
requesterId: viewerId,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
})
priority: DEFAULT_PRIORITY,
channel: "MANUAL",
queueId: undefined,
requesterId: viewerId,
categoryId: categoryId as Id<"ticketCategories">,
subcategoryId: subcategoryId as Id<"ticketSubcategories">,
machineId: machineContext?.machineId ? (machineContext.machineId as Id<"machines">) : undefined,
formTemplate: selectedFormKey !== "default" ? selectedFormKey : undefined,
customFields: customFieldsPayload.length > 0 ? customFieldsPayload : undefined,
})
if (plainDescription.length > 0) {
const MAX_COMMENT_CHARS = 20000
@ -128,6 +225,8 @@ export function PortalTicketForm() {
toast.success("Chamado criado com sucesso!", { id: "portal-new-ticket" })
setAttachments([])
setCustomFieldValues({})
setSelectedFormKey("default")
router.replace(`/portal/tickets/${id}`)
} catch (error) {
console.error(error)
@ -162,6 +261,27 @@ export function PortalTicketForm() {
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-6">
{forms.length > 1 ? (
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
<Select value={selectedFormKey} onValueChange={handleFormSelection} disabled={isSubmitting || machineInactive}>
<SelectTrigger className="h-9 rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0">
<SelectValue placeholder="Selecione uma opção" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{forms.map((formOption) => (
<SelectItem key={formOption.key} value={formOption.key} className="text-sm">
{formOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedForm?.description ? (
<p className="text-xs text-neutral-500">{selectedForm.description}</p>
) : null}
</div>
) : null}
<div className="space-y-3">
<div className="space-y-1">
<label htmlFor="subject" className="flex items-center gap-1 text-sm font-medium text-neutral-800">
@ -216,6 +336,179 @@ export function PortalTicketForm() {
subcategoryLabel="Subcategoria *"
secondaryEmptyLabel="Selecione uma categoria"
/>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="grid gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `portal-custom-field-${field.id}`
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
const labelSuffix = isRequiredStar ? <span className="text-red-500">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
const spanClass =
field.type === "boolean" || field.key.includes("observacao") || field.key.includes("permissao")
? "sm:col-span-2"
: ""
if (field.type === "boolean") {
return (
<label
key={field.id}
htmlFor={fieldId}
className={cn(
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-800",
spanClass || "sm:col-span-2"
)}
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field.id, event.target.checked)}
disabled={isSubmitting || machineInactive}
/>
<span>
{field.label} {labelSuffix}
</span>
</label>
)
}
if (field.type === "select") {
return (
<div key={field.id} className={cn("space-y-1", spanClass)}>
<span className="flex items-center gap-1 text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</span>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field.id, selected)}
disabled={isSubmitting || machineInactive}
>
<SelectTrigger className="h-9 rounded-lg border border-slate-300 bg-white px-3 text-left text-sm font-medium text-neutral-800 shadow-sm focus:ring-0">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className="text-sm">
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</div>
)
}
if (field.type === "number") {
return (
<div key={field.id} className={cn("space-y-1", spanClass)}>
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
disabled={isSubmitting || machineInactive}
/>
{helpText}
</div>
)
}
if (field.type === "date") {
const parsedDate =
typeof value === "string" && value ? parseISO(value) : undefined
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
return (
<div key={field.id} className={cn("space-y-1", spanClass)}>
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
<Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={isSubmitting || machineInactive}
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!isValidDate && "text-muted-foreground"
)}
>
<span>
{isValidDate
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
</span>
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? (parsedDate as Date) : undefined}
onSelect={(selected) => {
handleCustomFieldChange(field.id, selected ? format(selected, "yyyy-MM-dd") : "")
setOpenCalendarField(null)
}}
initialFocus
captionLayout="dropdown"
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
/>
</PopoverContent>
</Popover>
{helpText}
</div>
)
}
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
return (
<div key={field.id} className={cn("space-y-1", spanClass)}>
<label htmlFor={fieldId} className="flex items-center gap-1 text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{shouldUseTextarea ? (
<textarea
id={fieldId}
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10 disabled:cursor-not-allowed"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
disabled={isSubmitting || machineInactive}
/>
) : (
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field.id, event.target.value)}
disabled={isSubmitting || machineInactive}
/>
)}
{helpText}
</div>
)
})}
</div>
) : null}
{customFieldsInvalid ? (
<p className="text-xs font-semibold text-red-500">
Preencha todos os campos obrigatórios antes de registrar o chamado.
</p>
) : null}
<div className="space-y-1">
<span className="text-sm font-medium text-neutral-800">Anexos (opcional)</span>
<Dropzone
@ -254,4 +547,3 @@ export function PortalTicketForm() {
</Card>
)
}
const allowTicketMentions = true

View file

@ -12,10 +12,10 @@ import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Pie, PieChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa",
@ -52,6 +52,20 @@ export function BacklogReport() {
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const mostCriticalPriority = useMemo(() => {
if (!data) return null
const entries = Object.entries(data.priorityCounts) as Array<[string, number]>
@ -119,17 +133,13 @@ export function BacklogReport() {
</CardDescription>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="hidden w-56 md:flex">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 md:w-56"
/>
<ToggleGroup
type="single"

View file

@ -12,13 +12,13 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Progress } from "@/components/ui/progress"
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import type { ChartConfig } from "@/components/ui/chart"
import { formatHoursCompact } from "@/lib/utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type HoursItem = {
companyId: string
@ -58,6 +58,20 @@ export function HoursReport() {
api.companies.list,
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const filtered = useMemo(() => {
const items = data?.items ?? []
const q = query.trim().toLowerCase()
@ -178,17 +192,13 @@ export function HoursReport() {
onChange={(e) => setQuery(e.target.value)}
className="h-9 w-full min-w-56 sm:w-72"
/>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full min-w-56 sm:w-64">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-64"
/>
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>

View file

@ -11,13 +11,13 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { Area, AreaChart, Bar, BarChart, CartesianGrid, XAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
import { formatDateDM, formatDateDMY, formatHoursCompact } from "@/lib/utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
const agentProductivityChartConfig = {
resolved: {
@ -71,6 +71,21 @@ export function SlaReport() {
enabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: Id<"companies">; name: string }> | undefined
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
const base: SearchableComboboxOption[] = [{ value: "all", label: "Todas as empresas" }]
if (!companies || companies.length === 0) {
return base
}
const sorted = [...companies].sort((a, b) => a.name.localeCompare(b.name, "pt-BR"))
return [
base[0],
...sorted.map((company) => ({
value: company.id,
label: company.name,
})),
]
}, [companies])
const queueTotal = useMemo(
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
[data]
@ -142,17 +157,13 @@ export function SlaReport() {
</div>
<CardAction>
<div className="flex flex-wrap items-center justify-end gap-2 md:gap-3">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<SearchableCombobox
value={companyId}
onValueChange={(next) => setCompanyId(next ?? "all")}
options={companyOptions}
placeholder="Todas as empresas"
className="w-full min-w-56 sm:w-56"
/>
<ToggleGroup
type="single"

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"
import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
@ -14,9 +14,18 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import type { TicketStatus } from "@/lib/schemas/ticket"
type ClosingTemplate = { id: string; title: string; body: string }
type TicketLinkSuggestion = {
id: string
reference: number
subject: string
status: string
priority: string
}
const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
@ -100,6 +109,21 @@ const formatDurationLabel = (ms: number) => {
return `${minutes}min`
}
const STATUS_LABELS: Record<TicketStatus, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}
function formatTicketStatusLabel(status: string) {
const normalized = (status ?? "").toUpperCase()
if (normalized in STATUS_LABELS) {
return STATUS_LABELS[normalized as TicketStatus]
}
return status
}
export function CloseTicketDialog({
open,
onOpenChange,
@ -164,23 +188,35 @@ export function CloseTicketDialog({
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const [linkedReference, setLinkedReference] = useState<string>("")
const [linkSuggestions, setLinkSuggestions] = useState<TicketLinkSuggestion[]>([])
const [linkedTicketSelection, setLinkedTicketSelection] = useState<TicketLinkSuggestion | null>(null)
const [showLinkSuggestions, setShowLinkSuggestions] = useState(false)
const [isSearchingLinks, setIsSearchingLinks] = useState(false)
const linkSuggestionsAbortRef = useRef<AbortController | null>(null)
const linkedReferenceInputRef = useRef<HTMLInputElement | null>(null)
const suggestionHideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [reopenWindowDays, setReopenWindowDays] = useState<string>("14")
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
const normalizedReference = useMemo(() => {
const digits = linkedReference.replace(/[^0-9]/g, "").trim()
if (!digits) return null
const parsed = Number(digits)
if (!digitsOnlyReference) return null
const parsed = Number(digitsOnlyReference)
if (!Number.isFinite(parsed) || parsed <= 0) return null
if (ticketReference && parsed === ticketReference) return null
return parsed
}, [linkedReference, ticketReference])
}, [digitsOnlyReference, ticketReference])
const linkedTicket = useQuery(
api.tickets.findByReference,
actorId && normalizedReference ? { tenantId, viewerId: actorId, reference: normalizedReference } : "skip"
) as { id: Id<"tickets">; reference: number; subject: string; status: string } | null | undefined
const isLinkLoading = Boolean(actorId && normalizedReference && linkedTicket === undefined)
const linkNotFound = Boolean(normalizedReference && linkedTicket === null && !isLinkLoading)
const hasSufficientDigits = digitsOnlyReference.length >= 3
const linkedTicketCandidate = linkedTicketSelection ?? (linkedTicket ?? null)
const linkNotFound = Boolean(
hasSufficientDigits && !isLinkLoading && !linkedTicketSelection && linkedTicket === null && linkSuggestions.length === 0
)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -198,8 +234,21 @@ export function CloseTicketDialog({
setInternalMinutes("0")
setExternalHours("0")
setExternalMinutes("0")
setLinkedReference("")
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
}, [open])
useEffect(() => {
return () => {
linkSuggestionsAbortRef.current?.abort()
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
}
}
}, [])
useEffect(() => {
if (!open) return
if (templates.length > 0 && !selectedTemplateId && !message) {
@ -232,11 +281,138 @@ export function CloseTicketDialog({
}
}, [shouldAdjustTime])
useEffect(() => {
if (!open) return
const rawQuery = linkedReference.trim()
if (rawQuery.length < 2) {
linkSuggestionsAbortRef.current?.abort()
setIsSearchingLinks(false)
setLinkSuggestions([])
setShowLinkSuggestions(false)
return
}
const digitsForSelection = String(linkedTicketSelection?.reference ?? "")
if (linkedTicketSelection && digitsForSelection === digitsOnlyReference) {
setLinkSuggestions([])
setIsSearchingLinks(false)
return
}
linkSuggestionsAbortRef.current?.abort()
const controller = new AbortController()
linkSuggestionsAbortRef.current = controller
setIsSearchingLinks(true)
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
setShowLinkSuggestions(true)
}
fetch(`/api/tickets/mentions?q=${encodeURIComponent(rawQuery)}`, {
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
return { items: [] as TicketLinkSuggestion[] }
}
const json = (await response.json()) as { items?: Array<{ id: string; reference: number; subject?: string | null; status?: string | null; priority?: string | null }> }
const items = Array.isArray(json.items) ? json.items : []
return {
items: items
.filter((item) => String(item.id) !== ticketId && Number(item.reference) !== ticketReference)
.map((item) => ({
id: String(item.id),
reference: Number(item.reference),
subject: item.subject ?? "",
status: item.status ?? "PENDING",
priority: item.priority ?? "MEDIUM",
})),
}
})
.then(({ items }) => {
if (controller.signal.aborted) {
return
}
setLinkSuggestions(items)
if (linkedReferenceInputRef.current && document.activeElement === linkedReferenceInputRef.current) {
setShowLinkSuggestions(true)
}
})
.catch((error) => {
if ((error as Error).name !== "AbortError") {
console.error("Falha ao buscar tickets para vincular", error)
}
})
.finally(() => {
if (!controller.signal.aborted) {
setIsSearchingLinks(false)
}
})
return () => {
controller.abort()
}
}, [open, linkedReference, digitsOnlyReference, linkedTicketSelection, ticketId, ticketReference])
const handleTemplateSelect = (template: ClosingTemplate) => {
setSelectedTemplateId(template.id)
setMessage(hydrateTemplateBody(template.body))
}
const handleLinkedReferenceChange = (value: string) => {
setLinkedReference(value)
const digits = value.replace(/[^0-9]/g, "").trim()
if (value.trim().length === 0) {
setLinkedTicketSelection(null)
setLinkSuggestions([])
setShowLinkSuggestions(false)
return
}
if (linkedTicketSelection && String(linkedTicketSelection.reference) !== digits) {
setLinkedTicketSelection(null)
}
if (value.trim().length >= 2) {
setShowLinkSuggestions(true)
} else {
setShowLinkSuggestions(false)
}
}
const handleLinkedReferenceFocus = () => {
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
suggestionHideTimeoutRef.current = null
}
if (linkSuggestions.length > 0) {
setShowLinkSuggestions(true)
}
}
const handleLinkedReferenceBlur = () => {
suggestionHideTimeoutRef.current = setTimeout(() => {
setShowLinkSuggestions(false)
}, 150)
}
const handleSelectLinkSuggestion = (suggestion: TicketLinkSuggestion) => {
if (suggestionHideTimeoutRef.current) {
clearTimeout(suggestionHideTimeoutRef.current)
suggestionHideTimeoutRef.current = null
}
setLinkedTicketSelection(suggestion)
setLinkedReference(`#${suggestion.reference}`)
setShowLinkSuggestions(false)
setLinkSuggestions([])
}
const handleLinkedReferenceKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
if (linkSuggestions.length > 0) {
event.preventDefault()
handleSelectLinkSuggestion(linkSuggestions[0])
}
}
}
const handleSubmit = async () => {
if (!actorId) {
toast.error("É necessário estar autenticado para encerrar o ticket.")
@ -296,7 +472,7 @@ export function CloseTicketDialog({
setIsSubmitting(false)
return
}
if (linkNotFound || !linkedTicket) {
if (linkNotFound || !linkedTicketCandidate) {
toast.error("Não encontramos o ticket informado para vincular. Verifique o número e tente novamente.", {
id: "close-ticket",
})
@ -320,7 +496,7 @@ export function CloseTicketDialog({
await resolveTicketMutation({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
resolvedWithTicketId: linkedTicket ? (linkedTicket.id as Id<"tickets">) : undefined,
resolvedWithTicketId: linkedTicketCandidate ? (linkedTicketCandidate.id as Id<"tickets">) : undefined,
reopenWindowDays: Number.isFinite(reopenDaysNumber) ? reopenDaysNumber : undefined,
})
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
@ -399,13 +575,51 @@ export function CloseTicketDialog({
<Label htmlFor="linked-reference" className="text-sm font-medium text-neutral-800">
Ticket relacionado (opcional)
</Label>
<Input
id="linked-reference"
value={linkedReference}
onChange={(event) => setLinkedReference(event.target.value)}
placeholder="Número do ticket relacionado (ex.: 12345)"
disabled={isSubmitting}
/>
<div className="relative">
<Input
id="linked-reference"
ref={linkedReferenceInputRef}
value={linkedReference}
onChange={(event) => handleLinkedReferenceChange(event.target.value)}
onFocus={handleLinkedReferenceFocus}
onBlur={handleLinkedReferenceBlur}
onKeyDown={handleLinkedReferenceKeyDown}
placeholder="Buscar por número ou assunto do ticket"
disabled={isSubmitting}
/>
{showLinkSuggestions ? (
<div className="absolute left-0 right-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-lg border border-slate-200 bg-white shadow-lg">
{isSearchingLinks ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-3" /> Buscando tickets relacionados...
</div>
) : linkSuggestions.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">Nenhum ticket encontrado.</div>
) : (
linkSuggestions.map((suggestion) => {
const statusLabel = formatTicketStatusLabel(suggestion.status)
return (
<button
key={suggestion.id}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => handleSelectLinkSuggestion(suggestion)}
className="flex w-full flex-col gap-1 px-3 py-2 text-left text-sm transition hover:bg-slate-100 focus:bg-slate-100"
>
<span className="font-semibold text-neutral-900">
#{suggestion.reference}
</span>
{suggestion.subject ? (
<span className="text-xs text-neutral-600">{suggestion.subject}</span>
) : null}
<span className="text-xs text-neutral-500">Status: {statusLabel}</span>
</button>
)
})
)}
</div>
) : null}
</div>
{linkedReference.trim().length === 0 ? (
<p className="text-xs text-neutral-500">Informe o número de outro ticket quando o atendimento estiver relacionado.</p>
) : isLinkLoading ? (
@ -414,9 +628,9 @@ export function CloseTicketDialog({
</p>
) : linkNotFound ? (
<p className="text-xs text-red-500">Ticket não encontrado ou sem acesso permitido. Verifique o número informado.</p>
) : linkedTicket ? (
) : linkedTicketCandidate ? (
<p className="text-xs text-emerald-600">
Será registrado vínculo com o ticket #{linkedTicket.reference} {linkedTicket.subject ?? "Sem assunto"}
Será registrado vínculo com o ticket #{linkedTicketCandidate.reference} {linkedTicketCandidate.subject ?? "Sem assunto"}
</p>
) : null}
</div>

View file

@ -1,7 +1,9 @@
"use client"
import { z } from "zod"
import { useEffect, useMemo, useState } from "react"
import { useEffect, useMemo, useRef, useState } from "react"
import { format, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale"
import type { Doc, Id } from "@/convex/_generated/dataModel"
import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket"
import { useMutation, useQuery } from "convex/react"
@ -24,8 +26,13 @@ import { CategorySelectFields } from "@/components/tickets/category-select"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useDefaultQueues } from "@/hooks/use-default-queues"
import { cn } from "@/lib/utils"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react"
type CustomerOption = {
id: string
@ -90,23 +97,6 @@ function RequesterPreview({ customer, company }: RequesterPreviewProps) {
const NO_COMPANY_VALUE = "__no_company__"
type TicketFormFieldDefinition = {
id: string
key: string
label: string
type: string
required: boolean
description: string
options: Array<{ value: string; label: string }>
}
type TicketFormDefinition = {
key: string
label: string
description: string
fields: TicketFormFieldDefinition[]
}
const schema = z.object({
subject: z.string().default(""),
summary: z.string().optional(),
@ -175,6 +165,21 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[companiesRemote]
)
const ensureTicketFormDefaultsMutation = useMutation(api.tickets.ensureTicketFormDefaults)
const hasEnsuredFormsRef = useRef(false)
useEffect(() => {
if (!convexUserId || hasEnsuredFormsRef.current) return
hasEnsuredFormsRef.current = true
ensureTicketFormDefaultsMutation({
tenantId: DEFAULT_TENANT_ID,
actorId: convexUserId as Id<"users">,
}).catch((error) => {
console.error("Falha ao preparar formulários personalizados", error)
hasEnsuredFormsRef.current = false
})
}, [convexUserId, ensureTicketFormDefaultsMutation])
const formsRemote = useQuery(
api.tickets.listTicketForms,
convexUserId ? { tenantId: DEFAULT_TENANT_ID, viewerId: convexUserId as Id<"users"> } : "skip"
@ -195,6 +200,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
const [selectedFormKey, setSelectedFormKey] = useState<string>("default")
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
const selectedForm = useMemo(() => forms.find((formDef) => formDef.key === selectedFormKey) ?? forms[0], [forms, selectedFormKey])
@ -225,7 +231,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
[attachments]
)
const priorityValue = form.watch("priority") as TicketPriority
const channelValue = form.watch("channel")
const queueValue = form.watch("queueName") ?? "NONE"
const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE"
@ -386,6 +391,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
useEffect(() => {
if (!open) {
setAssigneeInitialized(false)
setOpenCalendarField(null)
return
}
if (assigneeInitialized) return
@ -449,49 +455,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
if (selectedFormKey !== "default" && selectedForm?.fields?.length) {
for (const field of selectedForm.fields) {
const raw = customFieldValues[field.id]
const isBooleanField = field.type === "boolean"
const isEmpty =
raw === undefined ||
raw === null ||
(typeof raw === "string" && raw.trim().length === 0)
if (isBooleanField) {
const boolValue = Boolean(raw)
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
continue
}
if (field.required && isEmpty) {
toast.error(`Preencha o campo "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
if (isEmpty) {
continue
}
let value: unknown = raw
if (field.type === "number") {
const parsed = typeof raw === "number" ? raw : Number(raw)
if (!Number.isFinite(parsed)) {
toast.error(`Informe um valor numérico válido para "${field.label}".`, { id: "new-ticket" })
setLoading(false)
return
}
value = parsed
} else if (field.type === "boolean") {
value = Boolean(raw)
} else if (field.type === "date") {
value = String(raw)
} else {
value = String(raw)
}
customFieldsPayload.push({ fieldId: field.id as Id<"ticketFields">, value })
const normalized = normalizeCustomFieldInputs(selectedForm.fields, customFieldValues)
if (!normalized.ok) {
toast.error(normalized.message, { id: "new-ticket" })
setLoading(false)
return
}
customFieldsPayload = normalized.payload
}
setLoading(true)
toast.loading("Criando ticket…", { id: "new-ticket" })
@ -601,7 +571,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</div>
{forms.length > 1 ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Modelo de ticket</p>
<p className="text-sm font-semibold text-neutral-800">Tipo de solicitação</p>
<div className="mt-2 flex flex-wrap gap-2">
{forms.map((formDef) => (
<Button
@ -744,7 +714,6 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<FieldLabel className="flex items-center gap-1">
Solicitante <span className="text-destructive">*</span>
</FieldLabel>
<RequesterPreview customer={selectedRequester} company={selectedCompanyOption} />
<SearchableCombobox
value={requesterValue || null}
onValueChange={(nextValue) => {
@ -778,18 +747,27 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
placeholder={filteredCustomers.length === 0 ? "Nenhum usuário disponível" : "Selecionar solicitante"}
searchPlaceholder="Buscar por nome ou e-mail..."
disabled={filteredCustomers.length === 0}
renderValue={(option) =>
option ? (
<div className="flex flex-col">
<span className="truncate font-medium text-foreground">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
renderValue={(option) => {
if (!option) return <span className="text-muted-foreground">Selecionar solicitante</span>
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground">{option.label}</span>
{option.description ? (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</div>
{selectedCompanyOption && selectedCompanyOption.id !== NO_COMPANY_VALUE ? (
<Badge
variant="outline"
className="hidden shrink-0 rounded-full px-2.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground sm:inline-flex"
>
{selectedCompanyOption.name}
</Badge>
) : null}
</div>
) : (
<span className="text-muted-foreground">Selecionar solicitante</span>
)
}
}}
renderOption={(option) => {
const record = requesterById.get(option.value)
const initials = getInitials(record?.name, record?.email ?? option.label)
@ -862,34 +840,7 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Canal</FieldLabel>
<Select value={channelValue} onValueChange={(v) => form.setValue("channel", v as z.infer<typeof schema>["channel"])}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Canal" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
<SelectItem value="EMAIL" className={selectItemClass}>
E-mail
</SelectItem>
<SelectItem value="WHATSAPP" className={selectItemClass}>
WhatsApp
</SelectItem>
<SelectItem value="CHAT" className={selectItemClass}>
Chat
</SelectItem>
<SelectItem value="PHONE" className={selectItemClass}>
Telefone
</SelectItem>
<SelectItem value="API" className={selectItemClass}>
API
</SelectItem>
<SelectItem value="MANUAL" className={selectItemClass}>
Manual
</SelectItem>
</SelectContent>
</Select>
</Field>
{/* Canal removido da UI: padrão MANUAL será enviado */}
<Field>
<FieldLabel>Fila</FieldLabel>
<Select value={queueValue} onValueChange={(v) => form.setValue("queueName", v === "NONE" ? null : v)}>
@ -935,119 +886,180 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
</Select>
</Field>
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="space-y-4 rounded-xl border border-slate-200 bg-white px-4 py-4">
<p className="text-sm font-semibold text-neutral-800">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const labelSuffix = field.required ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
if (field.type === "boolean") {
return (
<div
key={field.id}
className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2"
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="date"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</div>
{selectedFormKey !== "default" && selectedForm.fields.length > 0 ? (
<div className="grid gap-4 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2 lg:col-span-2">
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
{selectedForm.fields.map((field) => {
const value = customFieldValues[field.id]
const fieldId = `custom-field-${field.id}`
const isRequiredStar = field.required && field.key !== "colaborador_patrimonio"
const labelSuffix = isRequiredStar ? <span className="text-destructive">*</span> : null
const helpText = field.description ? (
<p className="text-xs text-neutral-500">{field.description}</p>
) : null
const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao")
const spanClass = shouldUseTextarea || field.type === "boolean" ? "sm:col-span-2" : ""
if (field.type === "boolean") {
return (
<div
key={field.id}
className={cn(
"flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
spanClass,
"sm:col-span-2"
)}
>
<input
id={fieldId}
type="checkbox"
className="size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
checked={Boolean(value)}
onChange={(event) => handleCustomFieldChange(field, event.target.checked)}
/>
<div className="flex flex-col">
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
{field.label} {labelSuffix}
</label>
{helpText}
</div>
</div>
)
}
if (field.type === "select") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Select
value={typeof value === "string" ? value : ""}
onValueChange={(selected) => handleCustomFieldChange(field, selected)}
>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent className="rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
{field.options.map((option) => (
<SelectItem key={option.value} value={option.value} className={selectItemClass}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{helpText}
</Field>
)
}
if (field.type === "number") {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
type="number"
inputMode="decimal"
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
if (field.type === "date") {
const parsedDate =
typeof value === "string" && value ? parseISO(value) : undefined
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn(
"w-full justify-between gap-2 text-left font-normal",
!isValidDate && "text-muted-foreground"
)}
>
<span>
{isValidDate
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
</span>
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={isValidDate ? (parsedDate as Date) : undefined}
onSelect={(selected) => {
handleCustomFieldChange(
field,
selected ? format(selected, "yyyy-MM-dd") : ""
)
setOpenCalendarField(null)
}}
initialFocus
captionLayout="dropdown"
startMonth={new Date(1900, 0)}
endMonth={new Date(new Date().getFullYear() + 5, 11)}
locale={ptBR}
/>
</PopoverContent>
</Popover>
{helpText}
</Field>
)
}
if (shouldUseTextarea) {
return (
<Field key={field.id} className={cn("flex-col", spanClass, "sm:col-span-2")}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<textarea
id={fieldId}
className="min-h-[90px] rounded-lg border border-slate-300 px-3 py-2 text-sm text-neutral-800 shadow-sm focus:border-neutral-900 focus:outline-none focus:ring-2 focus:ring-neutral-900/10"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
}
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {labelSuffix}
</FieldLabel>
<Input
id={fieldId}
value={typeof value === "string" ? value : ""}
onChange={(event) => handleCustomFieldChange(field, event.target.value)}
/>
{helpText}
</Field>
)
})}
</div>
) : null}
</FieldGroup>
</FieldSet>
</form>

View file

@ -32,10 +32,44 @@ function formatRelative(timestamp: Date | null | undefined) {
export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
const router = useRouter()
const { session, convexUserId, role: authRole } = useAuth()
const { session, convexUserId, role: authRole, machineContext } = useAuth()
const submitCsat = useMutation(api.tickets.submitCsat)
const viewerRole = (authRole ?? session?.user.role ?? "").toUpperCase()
const deriveViewerRole = () => {
const authRoleNormalized = authRole?.toLowerCase()?.trim()
const machinePersona = machineContext?.persona ?? session?.user.machinePersona ?? null
const assignedRole = machineContext?.assignedUserRole ?? null
const sessionRole = session?.user.role?.toLowerCase()?.trim()
if (authRoleNormalized && authRoleNormalized !== "machine") {
return authRoleNormalized.toUpperCase()
}
if (authRoleNormalized === "machine" && machinePersona) {
return machinePersona.toUpperCase()
}
if (machinePersona) {
return machinePersona.toUpperCase()
}
if (assignedRole) {
return assignedRole.toUpperCase()
}
if (sessionRole && sessionRole !== "machine") {
return sessionRole.toUpperCase()
}
if (sessionRole === "machine") {
return "COLLABORATOR"
}
return "COLLABORATOR"
}
const viewerRole = deriveViewerRole()
const viewerEmail = session?.user.email?.trim().toLowerCase() ?? ""
const viewerId = convexUserId as Id<"users"> | undefined
@ -187,6 +221,10 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
</span>
{ratedAtRelative ? `${ratedAtRelative}` : null}
</p>
) : viewerIsStaff ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-neutral-600">
Nenhuma avaliação registrada ainda.
</div>
) : null}
{canSubmit ? (
<div className="space-y-2">

View file

@ -0,0 +1,195 @@
"use client"
import * as React from "react"
import type { DropdownProps } from "react-day-picker"
import { DayPicker } from "react-day-picker"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import "react-day-picker/style.css"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
type OptionElement = React.ReactElement<{
value: string | number
children: React.ReactNode
disabled?: boolean
}>
type CalendarDropdownProps = DropdownProps & { children?: React.ReactNode }
const buildOptions = (options?: DropdownProps["options"], children?: React.ReactNode) => {
if (options && options.length > 0) {
return options.map((option) => ({
value: option.value,
label: option.label,
disabled: option.disabled,
}))
}
return React.Children.toArray(children)
.filter((child): child is OptionElement => React.isValidElement(child))
.map((child) => ({
value: child.props.value,
label: child.props.children,
disabled: child.props.disabled ?? false,
}))
}
function CalendarDropdown(props: CalendarDropdownProps) {
const { value, onChange, options: optionProp, children } = props
const disabled = props.disabled
const ariaLabel = props["aria-label"]
const options = React.useMemo(() => buildOptions(optionProp, children), [optionProp, children])
const stringValue = value !== undefined && value !== null ? String(value) : ""
const handleChange = (next: string) => {
const match = options.find((option) => String(option.value) === next)
const payload = match ? match.value : next
onChange?.({
target: { value: payload },
} as React.ChangeEvent<HTMLSelectElement>)
}
const isYearDropdown = options.every((option) => String(option.value).length === 4)
const triggerWidth = isYearDropdown ? "w-[96px]" : "w-[108px]"
const displayText = React.useMemo(() => {
if (!options.length) return ""
const selected = options.find((o) => String(o.value) === stringValue)
if (!selected) return ""
if (isYearDropdown) return String(selected.label)
const label = String(selected.label)
const abbr = label.slice(0, 3)
return abbr.charAt(0).toUpperCase() + abbr.slice(1).toLowerCase()
}, [options, stringValue, isYearDropdown])
return (
<Select value={stringValue} onValueChange={handleChange} disabled={disabled}>
<SelectTrigger
aria-label={ariaLabel}
className={cn(
"h-8 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700 shadow-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-neutral-900/10 disabled:cursor-not-allowed disabled:opacity-60",
triggerWidth
)}
>
{/* Mostra mês abreviado no trigger; lista usa label completo */}
<span className="truncate">
{displayText}
</span>
</SelectTrigger>
<SelectContent
align="start"
className="max-h-72 min-w-[var(--radix-select-trigger-width)] rounded-2xl border border-[#00e8ff]/20 bg-white/95 text-neutral-800 shadow-[0_10px_40px_-10px_rgba(0,155,177,0.25)] backdrop-blur-sm"
>
{options.map((option) => (
<SelectItem
key={String(option.value)}
value={String(option.value)}
disabled={option.disabled}
className="rounded-lg text-sm font-medium text-neutral-700 focus:bg-slate-100 focus:text-neutral-900"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "dropdown",
startMonth,
endMonth,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
startMonth={startMonth}
endMonth={endMonth}
className={cn(
"mx-auto flex w-[320px] flex-col gap-3 rounded-2xl bg-transparent p-4 text-neutral-900 shadow-none",
className
)}
classNames={{
months: "flex flex-col items-center gap-4",
// Layout em grid de 3 colunas para posicionar setas à esquerda/direita
month: "grid grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-3",
month_caption: "col-start-2 col-end-3 flex items-center justify-center gap-2 px-0",
dropdowns: "flex items-center gap-2",
nav: "flex items-center gap-1",
button_previous: cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"col-start-1 row-start-1 size-8 rounded-full border border-transparent text-neutral-500 hover:border-slate-200 hover:bg-slate-50 hover:text-neutral-900 focus-visible:ring-2 focus-visible:ring-neutral-900/10"
),
button_next: cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"col-start-3 row-start-1 size-8 rounded-full border border-transparent text-neutral-500 hover:border-slate-200 hover:bg-slate-50 hover:text-neutral-900 focus-visible:ring-2 focus-visible:ring-neutral-900/10"
),
chevron: "size-4",
month_grid: "col-span-3 w-full border-collapse",
weekdays:
"grid grid-cols-7 text-[0.7rem] font-medium capitalize text-muted-foreground",
weekday: "flex h-8 items-center justify-center",
week: "grid grid-cols-7 gap-1",
day: "relative flex h-9 items-center justify-center text-sm focus-within:relative focus-within:z-20",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"size-9 rounded-full font-medium text-neutral-700 transition hover:bg-slate-100 hover:text-neutral-900 data-[selected]:rounded-full data-[selected]:bg-neutral-900 data-[selected]:text-neutral-50 data-[selected]:ring-0 data-[range-start]:rounded-s-full data-[range-end]:rounded-e-full"
),
// Hoje: estilos principais irão no botão via modifiersClassNames
today: "text-neutral-900",
outside: "text-muted-foreground opacity-40",
disabled: "text-muted-foreground opacity-30 line-through",
range_middle: "bg-neutral-900/10 text-neutral-900",
hidden: "invisible",
...classNames,
}}
modifiersClassNames={{
today:
"[&_button]:bg-[#00e8ff]/20 [&_button]:text-neutral-900 [&_button]:ring-1 [&_button]:ring-[#00d6eb] [&_button]:ring-offset-1 [&_button]:ring-offset-white",
}}
// Setas ao lado do caption
navLayout="around"
components={{
Dropdown: CalendarDropdown,
// Tornar os botões de navegação estáticos para encaixar no grid
PreviousMonthButton: (props) => {
const { className, children, style, ...rest } = props
return (
<button
type="button"
{...rest}
className={className}
style={{ ...(style || {}), position: "static" }}
>
{children}
</button>
)
},
NextMonthButton: (props) => {
const { className, children, style, ...rest } = props
return (
<button
type="button"
{...rest}
className={className}
style={{ ...(style || {}), position: "static" }}
>
{children}
</button>
)
},
}}
{...props}
/>
)
}

View file

@ -93,7 +93,7 @@ export function SearchableCombobox({
aria-expanded={open}
disabled={disabled}
className={cn(
"flex min-h-[42px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
"flex min-h-[46px] w-full items-center justify-between rounded-full border border-input bg-background px-3 py-2.5 text-sm font-medium text-foreground shadow-sm transition focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>

View file

@ -0,0 +1,70 @@
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketFormFieldDefinition } from "./ticket-form-types"
type CustomFieldPayload = { fieldId: Id<"ticketFields">; value: unknown }
type NormalizeResult =
| { ok: true; payload: CustomFieldPayload[] }
| { ok: false; message: string }
function isEmptyValue(value: unknown): boolean {
if (value === undefined || value === null) return true
if (typeof value === "string" && value.trim().length === 0) return true
return false
}
export function normalizeCustomFieldInputs(
fields: TicketFormFieldDefinition[],
values: Record<string, unknown>
): NormalizeResult {
const payload: CustomFieldPayload[] = []
for (const field of fields) {
const raw = values[field.id]
if (field.type === "boolean") {
const boolValue = Boolean(raw)
payload.push({ fieldId: field.id as Id<"ticketFields">, value: boolValue })
continue
}
if (isEmptyValue(raw)) {
if (field.required) {
return { ok: false, message: `Preencha o campo "${field.label}".` }
}
continue
}
if (field.type === "number") {
const normalized = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."))
if (!Number.isFinite(normalized)) {
return { ok: false, message: `Informe um valor numérico válido para "${field.label}".` }
}
payload.push({ fieldId: field.id as Id<"ticketFields">, value: normalized })
continue
}
if (field.type === "date") {
payload.push({ fieldId: field.id as Id<"ticketFields">, value: String(raw) })
continue
}
const value = typeof raw === "string" ? raw.trim() : raw
payload.push({ fieldId: field.id as Id<"ticketFields">, value })
}
return { ok: true, payload }
}
export function hasMissingRequiredCustomFields(
fields: TicketFormFieldDefinition[],
values: Record<string, unknown>
): boolean {
return fields.some((field) => {
if (!field.required || field.type === "boolean") {
return false
}
const value = values[field.id]
return isEmptyValue(value)
})
}

View file

@ -0,0 +1,21 @@
export type TicketFormFieldOption = {
value: string
label: string
}
export type TicketFormFieldDefinition = {
id: string
key: string
label: string
type: string
required: boolean
description: string
options: TicketFormFieldOption[]
}
export type TicketFormDefinition = {
key: string
label: string
description: string
fields: TicketFormFieldDefinition[]
}

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest"
import { normalizeCustomFieldInputs } from "../src/lib/ticket-form-helpers"
import type { TicketFormFieldDefinition } from "../src/lib/ticket-form-types"
describe("ticket form helpers", () => {
const baseFields: TicketFormFieldDefinition[] = [
{
id: "field-nome",
key: "nome",
label: "Nome",
type: "text",
required: true,
description: "",
options: [],
},
{
id: "field-data",
key: "data",
label: "Data",
type: "date",
required: false,
description: "",
options: [],
},
{
id: "field-numero",
key: "numero",
label: "Número",
type: "number",
required: true,
description: "",
options: [],
},
{
id: "field-select",
key: "tipo",
label: "Tipo",
type: "select",
required: true,
description: "",
options: [
{ value: "nova", label: "Nova contratação" },
{ value: "substituicao", label: "Substituição" },
],
},
]
it("normalizes values for required fields", () => {
const result = normalizeCustomFieldInputs(baseFields, {
"field-nome": " Ana Silva ",
"field-numero": "123",
"field-select": "nova",
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.payload).toEqual([
{ fieldId: "field-nome", value: "Ana Silva" },
{ fieldId: "field-numero", value: 123 },
{ fieldId: "field-select", value: "nova" },
])
}
})
it("fails when a required field is empty", () => {
const result = normalizeCustomFieldInputs(baseFields, {
"field-nome": " ",
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.message).toContain("Nome")
}
})
})