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:
parent
e0ef66555d
commit
a8333c010f
28 changed files with 1752 additions and 455 deletions
|
|
@ -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": {}
|
||||
}
|
||||
|
|
@ -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: [] }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
26
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
195
src/components/ui/calendar.tsx
Normal file
195
src/components/ui/calendar.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
70
src/lib/ticket-form-helpers.ts
Normal file
70
src/lib/ticket-form-helpers.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
21
src/lib/ticket-form-types.ts
Normal file
21
src/lib/ticket-form-types.ts
Normal 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[]
|
||||
}
|
||||
76
tests/ticket-form-helpers.test.ts
Normal file
76
tests/ticket-form-helpers.test.ts
Normal 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")
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue