From a8333c010fcc221f54f23c42dde8616da7a6c1ce Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 4 Nov 2025 11:51:08 -0300 Subject: [PATCH] fix(reports): remove truncation cap in range collectors to avoid dropped records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components.json | 44 +- convex/machines.ts | 18 +- convex/reports.ts | 132 ++++-- convex/tickets.ts | 169 +++++++ docs/alteracoes-2025-11-03.md | 11 +- package.json | 1 + pnpm-lock.yaml | 26 ++ src/app/globals.css | 9 + .../admin/alerts/admin-alerts-manager.tsx | 34 +- .../admin/devices/admin-devices-overview.tsx | 35 +- src/components/chart-area-interactive.tsx | 56 ++- src/components/charts/chart-open-priority.tsx | 37 +- .../charts/chart-opened-resolved.tsx | 35 +- src/components/charts/views-charts.tsx | 66 ++- src/components/portal/portal-ticket-card.tsx | 18 +- .../portal/portal-ticket-detail.tsx | 5 +- src/components/portal/portal-ticket-form.tsx | 318 ++++++++++++- src/components/reports/backlog-report.tsx | 34 +- src/components/reports/hours-report.tsx | 34 +- src/components/reports/sla-report.tsx | 35 +- .../tickets/close-ticket-dialog.tsx | 248 +++++++++- src/components/tickets/new-ticket-dialog.tsx | 436 +++++++++--------- src/components/tickets/ticket-csat-card.tsx | 42 +- src/components/ui/calendar.tsx | 195 ++++++++ src/components/ui/searchable-combobox.tsx | 2 +- src/lib/ticket-form-helpers.ts | 70 +++ src/lib/ticket-form-types.ts | 21 + tests/ticket-form-helpers.test.ts | 76 +++ 28 files changed, 1752 insertions(+), 455 deletions(-) create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/lib/ticket-form-helpers.ts create mode 100644 src/lib/ticket-form-types.ts create mode 100644 tests/ticket-form-helpers.test.ts diff --git a/components.json b/components.json index aaba37d..a574e35 100644 --- a/components.json +++ b/components.json @@ -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": {} +} \ No newline at end of file diff --git a/convex/machines.ts b/convex/machines.ts index 68ed346..972cbcb 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -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: [] } diff --git a/convex/reports.ts b/convex/reports.ts index 793bc48..ce9e031 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -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 = { 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[]> }).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[]> }).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) { diff --git a/convex/tickets.ts b/convex/tickets.ts index b3da0e0..e3ef772 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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 = { + 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 } diff --git a/docs/alteracoes-2025-11-03.md b/docs/alteracoes-2025-11-03.md index 801910d..fd32a8f 100644 --- a/docs/alteracoes-2025-11-03.md +++ b/docs/alteracoes-2025-11-03.md @@ -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. diff --git a/package.json b/package.json index ee4c948..d46d2c3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9df4324..5ea2ed9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/globals.css b/src/app/globals.css index 925a671..9677c57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/components/admin/alerts/admin-alerts-manager.tsx b/src/components/admin/alerts/admin-alerts-manager.tsx index a443a8d..ef921d9 100644 --- a/src/components/admin/alerts/admin-alerts-manager.tsx +++ b/src/components/admin/alerts/admin-alerts-manager.tsx @@ -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("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(() => { + 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 ( @@ -56,17 +72,13 @@ export function AdminAlertsManager() {
- + setCompanyId(next ?? "all")} + options={companyOptions} + placeholder="Todas as empresas" + className="w-full min-w-56 sm:w-64" + /> + + + + { + 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} + /> + + + {helpText} +
+ ) + } + + const shouldUseTextarea = field.key.includes("observacao") || field.key.includes("permissao") + + return ( +
+ + {shouldUseTextarea ? ( +