diff --git a/convex/tickets.ts b/convex/tickets.ts index aec1117..dc62aca 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -26,6 +26,7 @@ const PAUSE_REASON_LABELS: Record = { WAITING_THIRD_PARTY: "Aguardando terceiro", IN_PROCEDURE: "Em procedimento", }; +const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; @@ -65,6 +66,8 @@ type TemplateSummary = { defaultEnabled: boolean; }; +type TicketFieldScopeMap = Map[]>; + function plainTextLength(html: string): number { try { const text = String(html) @@ -200,6 +203,86 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise { + const uniqueScopes = Array.from(new Set(scopes)); + const result: TicketFieldScopeMap = new Map(); + for (const scope of uniqueScopes) { + const fields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_scope", (q) => q.eq("tenantId", tenantId).eq("scope", scope)) + .collect(); + result.set(scope, fields); + } + return result; +} + +async function fetchScopedFormSettings( + ctx: QueryCtx, + tenantId: string, + templateKey: string, + viewerId: Id<"users">, + viewerCompanyId: Id<"companies"> | null +): Promise[]> { + const tenantSettingsPromise = ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant_template_scope", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("scope", "tenant")) + .collect(); + + const companySettingsPromise = viewerCompanyId + ? ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant_template_company", (q) => + q.eq("tenantId", tenantId).eq("template", templateKey).eq("companyId", viewerCompanyId) + ) + .collect() + : Promise.resolve[]>([]); + + const userSettingsPromise = ctx.db + .query("ticketFormSettings") + .withIndex("by_tenant_template_user", (q) => q.eq("tenantId", tenantId).eq("template", templateKey).eq("userId", viewerId)) + .collect(); + + const [tenantSettings, companySettings, userSettings] = await Promise.all([ + tenantSettingsPromise, + companySettingsPromise, + userSettingsPromise, + ]); + + return [...tenantSettings, ...companySettings, ...userSettings]; +} + +function normalizeDateOnlyValue(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return null; + if (DATE_ONLY_REGEX.test(trimmed)) { + return trimmed; + } + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.toISOString().slice(0, 10); + } + const date = + value instanceof Date + ? value + : typeof value === "number" + ? new Date(value) + : null; + if (!date || Number.isNaN(date.getTime())) { + return null; + } + return date.toISOString().slice(0, 10); +} + async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) { await ensureTicketFormTemplatesForTenant(ctx, tenantId); const now = Date.now(); @@ -776,17 +859,11 @@ function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { val return { value }; } case "date": { - if (typeof raw === "number") { - if (!Number.isFinite(raw)) { - throw new ConvexError(`Data inválida para o campo ${field.label}`); - } - return { value: raw }; - } - const parsed = Date.parse(String(raw)); - if (!Number.isFinite(parsed)) { + const normalized = normalizeDateOnlyValue(raw); + if (!normalized) { throw new ConvexError(`Data inválida para o campo ${field.label}`); } - return { value: parsed }; + return { value: normalized }; } case "boolean": { if (typeof raw === "boolean") { @@ -883,16 +960,59 @@ async function normalizeCustomFieldValues( function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) { if (!entries || entries.length === 0) return {}; return entries.reduce>((acc, entry) => { + let value: unknown = entry.value; + if (entry.type === "date") { + value = normalizeDateOnlyValue(entry.value) ?? entry.value; + } acc[entry.fieldKey] = { label: entry.label, type: entry.type, - value: entry.value, + value, displayValue: entry.displayValue, }; return acc; }, {}); } +type CustomFieldRecordEntry = { label: string; type: string; value: unknown; displayValue?: string } | undefined; + +function areValuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if ((a === null || a === undefined) && (b === null || b === undefined)) { + return true; + } + if (typeof a !== typeof b) { + return false; + } + if (typeof a === "number" && typeof b === "number") { + return Number.isNaN(a) && Number.isNaN(b); + } + if (typeof a === "object" && typeof b === "object") { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } + } + return false; +} + +function areCustomFieldEntriesEqual(a: CustomFieldRecordEntry, b: CustomFieldRecordEntry): boolean { + if (!a && !b) return true; + if (!a || !b) return false; + if (!areValuesEqual(a.value ?? null, b.value ?? null)) return false; + const prevDisplay = a.displayValue ?? null; + const nextDisplay = b.displayValue ?? null; + return prevDisplay === nextDisplay; +} + +function getCustomFieldRecordEntry( + record: Record, + key: string +): CustomFieldRecordEntry { + return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined; +} + const DEFAULT_TICKETS_LIST_LIMIT = 250; const MIN_TICKETS_LIST_LIMIT = 25; const MAX_TICKETS_LIST_LIMIT = 600; @@ -2387,27 +2507,24 @@ export const listTicketForms = query({ handler: async (ctx, { tenantId, viewerId, companyId }) => { const viewer = await requireUser(ctx, viewerId, tenantId) const viewerCompanyId = companyId ?? viewer.user.companyId ?? null + const viewerRole = (viewer.role ?? "").toUpperCase() + const templates = await fetchTemplateSummaries(ctx, tenantId) - const settings = await ctx.db - .query("ticketFormSettings") - .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) - .collect() + const scopes = templates.map((template) => template.key) + const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes) - const fieldDefinitions = await ctx.db - .query("ticketFields") - .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) - .collect() + const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT" + const settingsByTemplate = new Map[]>() - const fieldsByScope = new Map[]>() - for (const definition of fieldDefinitions) { - const scope = (definition.scope ?? "all").trim() - if (!fieldsByScope.has(scope)) { - fieldsByScope.set(scope, []) - } - fieldsByScope.get(scope)!.push(definition) + if (!staffOverride) { + await Promise.all( + templates.map(async (template) => { + const scopedSettings = await fetchScopedFormSettings(ctx, tenantId, template.key, viewer.user._id, viewerCompanyId) + settingsByTemplate.set(template.key, scopedSettings) + }) + ) } - const templates = await fetchTemplateSummaries(ctx, tenantId) const forms = [] as Array<{ key: string label: string @@ -2424,14 +2541,13 @@ export const listTicketForms = query({ }> for (const template of templates) { - let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], { - companyId: viewerCompanyId, - userId: viewer.user._id, - }) - const viewerRole = (viewer.role ?? "").toUpperCase() - if (viewerRole === "ADMIN" || viewerRole === "AGENT") { - enabled = true - } + const templateSettings = settingsByTemplate.get(template.key) ?? [] + let enabled = staffOverride + ? true + : resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, { + companyId: viewerCompanyId, + userId: viewer.user._id, + }) if (!enabled) { continue } @@ -3477,6 +3593,9 @@ export const updateCustomFields = mutation({ throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.") } + const previousEntries = (ticketDoc.customFields as NormalizedCustomField[] | undefined) ?? [] + const previousRecord = mapCustomFieldsToRecord(previousEntries) + const sanitizedInputs: CustomFieldInput[] = fields .filter((entry) => entry.value !== undefined) .map((entry) => ({ @@ -3491,6 +3610,40 @@ export const updateCustomFields = mutation({ ticketDoc.formTemplate ?? null ) + const nextRecord = mapCustomFieldsToRecord(normalized) + const metaByFieldKey = new Map< + string, + { fieldId?: Id<"ticketFields">; label: string; type: string } + >() + for (const entry of previousEntries) { + metaByFieldKey.set(entry.fieldKey, { + fieldId: entry.fieldId, + label: entry.label, + type: entry.type, + }) + } + for (const entry of normalized) { + metaByFieldKey.set(entry.fieldKey, { + fieldId: entry.fieldId, + label: entry.label, + type: entry.type, + }) + } + + const keyOrder = [...Object.keys(previousRecord), ...Object.keys(nextRecord)] + const changedKeys = Array.from(new Set(keyOrder)).filter((key) => { + const previousEntry = getCustomFieldRecordEntry(previousRecord, key) + const nextEntry = getCustomFieldRecordEntry(nextRecord, key) + return !areCustomFieldEntriesEqual(previousEntry, nextEntry) + }) + + if (changedKeys.length === 0) { + return { + customFields: previousRecord, + updatedAt: ticketDoc.updatedAt ?? Date.now(), + } + } + const now = Date.now() await ctx.db.patch(ticketId, { @@ -3505,18 +3658,28 @@ export const updateCustomFields = mutation({ actorId, actorName: viewer.user.name, actorAvatar: viewer.user.avatarUrl ?? undefined, - fields: normalized.map((field) => ({ - fieldId: field.fieldId, - fieldKey: field.fieldKey, - label: field.label, - type: field.type, - })), + fields: changedKeys.map((fieldKey) => { + const meta = metaByFieldKey.get(fieldKey) + const previous = getCustomFieldRecordEntry(previousRecord, fieldKey) + const next = getCustomFieldRecordEntry(nextRecord, fieldKey) + return { + fieldId: meta?.fieldId, + fieldKey, + label: meta?.label ?? fieldKey, + type: meta?.type ?? "text", + previousValue: previous?.value ?? null, + nextValue: next?.value ?? null, + previousDisplayValue: previous?.displayValue ?? null, + nextDisplayValue: next?.displayValue ?? null, + changeType: !previous ? "added" : !next ? "removed" : "updated", + } + }), }, createdAt: now, }) return { - customFields: mapCustomFieldsToRecord(normalized), + customFields: nextRecord, updatedAt: now, } }, diff --git a/src/app/ConvexClientProvider.tsx b/src/app/ConvexClientProvider.tsx index 99b2c1b..4363aed 100644 --- a/src/app/ConvexClientProvider.tsx +++ b/src/app/ConvexClientProvider.tsx @@ -1,5 +1,7 @@ "use client"; +import "@/lib/toast-patch"; + import { ConvexProvider, ConvexReactClient } from "convex/react"; import { ReactNode } from "react"; @@ -13,4 +15,3 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) { } return {children}; } - diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 8e03ab0..d42a490 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -26,7 +26,7 @@ import { TicketStatusBadge } from "@/components/tickets/status-badge" import { Spinner } from "@/components/ui/spinner" import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields" -import { mapTicketCustomFields } from "@/lib/ticket-custom-fields" +import { mapTicketCustomFields, formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields" const priorityLabel: Record = { LOW: "Baixa", @@ -192,14 +192,59 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { } if (event.type === "CUSTOM_FIELDS_UPDATED") { + type FieldPayload = { + label?: string + type?: string + previousValue?: unknown + nextValue?: unknown + previousDisplayValue?: string | null + nextDisplayValue?: string | null + } const fields = Array.isArray((payload as { fields?: unknown }).fields) - ? ((payload as { fields?: Array<{ label?: string }> }).fields ?? []) + ? ((payload as { fields?: FieldPayload[] }).fields ?? []) : [] - const labels = fields - .map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) - .filter((label) => label.length > 0) - const description = - labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados" + const hasValueDetails = fields.some((field) => { + if (!field) return false + return ( + Object.prototype.hasOwnProperty.call(field, "previousValue") || + Object.prototype.hasOwnProperty.call(field, "nextValue") || + Object.prototype.hasOwnProperty.call(field, "previousDisplayValue") || + Object.prototype.hasOwnProperty.call(field, "nextDisplayValue") + ) + }) + let description: string + if (hasValueDetails && fields.length > 0) { + const details = fields.map((field, index) => { + const label = + typeof field?.label === "string" && field.label.trim().length > 0 ? field.label.trim() : `Campo ${index + 1}` + const baseType = + typeof field?.type === "string" && field.type.trim().length > 0 ? field.type : "text" + const previousValue = formatTicketCustomFieldValue({ + type: baseType, + value: field?.previousValue, + displayValue: + typeof field?.previousDisplayValue === "string" && field.previousDisplayValue.trim().length > 0 + ? field.previousDisplayValue + : undefined, + }) + const nextValue = formatTicketCustomFieldValue({ + type: baseType, + value: field?.nextValue, + displayValue: + typeof field?.nextDisplayValue === "string" && field.nextDisplayValue.trim().length > 0 + ? field.nextDisplayValue + : undefined, + }) + return `${label}: ${previousValue} → ${nextValue}` + }) + description = details.join(" • ") + } else { + const labels = fields + .map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) + .filter((label) => label.length > 0) + description = + labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados" + } return { id: event.id, title: "Campos personalizados", diff --git a/src/components/tickets/ticket-csat-card.tsx b/src/components/tickets/ticket-csat-card.tsx index 52c611f..bdfcdc8 100644 --- a/src/components/tickets/ticket-csat-card.tsx +++ b/src/components/tickets/ticket-csat-card.tsx @@ -106,12 +106,12 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { }, [initialScore, initialComment, ratedAtTimestamp]) const effectiveScore = hasSubmitted ? score : hoverScore ?? score - const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER" - const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING" + const viewerIsAdmin = viewerRole === "ADMIN" + const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING" const canSubmit = Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted) const hasRating = hasSubmitted - const showCard = staffCanInspect || isRequester || hasSubmitted + const showCard = adminCanInspect || isRequester const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt]) @@ -181,7 +181,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) { Conte como foi sua experiência com este chamado. - {hasRating ? ( + {hasRating && !viewerIsAdmin ? (
Obrigado pelo feedback!
@@ -250,7 +250,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {

{comment}

) : null} - {viewerIsStaff && !hasRating ? ( + {viewerIsAdmin && !hasRating ? (

Nenhuma avaliação registrada para este chamado até o momento.

diff --git a/src/components/tickets/ticket-custom-fields.tsx b/src/components/tickets/ticket-custom-fields.tsx index bd3e87e..f531f16 100644 --- a/src/components/tickets/ticket-custom-fields.tsx +++ b/src/components/tickets/ticket-custom-fields.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -import { format, parseISO } from "date-fns" +import { format, parse } from "date-fns" import { ptBR } from "date-fns/locale" import { CalendarIcon, Pencil, X } from "lucide-react" @@ -35,6 +35,8 @@ type TicketCustomFieldsListProps = { actionSlot?: ReactNode } +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/ + const DEFAULT_FORM: TicketFormDefinition = { key: "default", label: "Chamado", @@ -42,6 +44,35 @@ const DEFAULT_FORM: TicketFormDefinition = { fields: [], } +function toIsoDateString(value: unknown): string { + if (!value && value !== 0) return "" + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return "" + if (ISO_DATE_REGEX.test(trimmed)) { + return trimmed + } + const parsed = new Date(trimmed) + return Number.isNaN(parsed.getTime()) ? "" : parsed.toISOString().slice(0, 10) + } + const date = + value instanceof Date + ? value + : typeof value === "number" + ? new Date(value) + : null + if (!date || Number.isNaN(date.getTime())) { + return "" + } + return date.toISOString().slice(0, 10) +} + +function parseIsoDate(value: string): Date | null { + if (!ISO_DATE_REGEX.test(value)) return null + const parsed = parse(value, "yyyy-MM-dd", new Date()) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + function buildInitialValues( fields: TicketFormFieldDefinition[], record?: TicketCustomFieldRecord | null @@ -61,17 +92,11 @@ function buildInitialValues( ? value : "" break - case "date": - if (typeof value === "number") { - const date = new Date(value) - result[field.id] = Number.isNaN(date.getTime()) ? "" : format(date, "yyyy-MM-dd") - } else if (typeof value === "string") { - const parsed = parseISO(value) - result[field.id] = Number.isNaN(parsed.getTime()) ? value : format(parsed, "yyyy-MM-dd") - } else { - result[field.id] = "" - } + case "date": { + const isoValue = toIsoDateString(value) + result[field.id] = isoValue break + } case "boolean": if (value === null || value === undefined) { result[field.id] = field.required ? false : null @@ -343,8 +368,10 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className {hasConfiguredFields ? ( -
- {selectedForm.fields.map((field) => renderFieldEditor(field))} +
+
+ {selectedForm.fields.map((field) => renderFieldEditor(field))} +
) : (

Nenhum campo configurado ainda.

@@ -383,8 +410,8 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className

Informações adicionais

- @@ -491,23 +518,28 @@ function renderFieldEditor(field: TicketFormFieldDefinition) { } if (field.type === "date") { + const isoValue = toIsoDateString(value) + const parsedDate = isoValue ? parseIsoDate(isoValue) : null return ( {field.label} {field.required ? * : null} - setOpenCalendarField(field.id)}> + setOpenCalendarField(open ? field.id : null)} + >