Improve custom field timeline and toasts

This commit is contained in:
Esdras Renan 2025-11-07 23:59:16 -03:00
parent f7aa17f229
commit a2f9d4bd1a
9 changed files with 549 additions and 101 deletions

View file

@ -26,6 +26,7 @@ const PAUSE_REASON_LABELS: Record<string, string> = {
WAITING_THIRD_PARTY: "Aguardando terceiro", WAITING_THIRD_PARTY: "Aguardando terceiro",
IN_PROCEDURE: "Em procedimento", IN_PROCEDURE: "Em procedimento",
}; };
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED"; type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED";
@ -65,6 +66,8 @@ type TemplateSummary = {
defaultEnabled: boolean; defaultEnabled: boolean;
}; };
type TicketFieldScopeMap = Map<string, Doc<"ticketFields">[]>;
function plainTextLength(html: string): number { function plainTextLength(html: string): number {
try { try {
const text = String(html) const text = String(html)
@ -200,6 +203,86 @@ async function fetchTemplateSummaries(ctx: AnyCtx, tenantId: string): Promise<Te
})); }));
} }
async function fetchTicketFieldsByScopes(
ctx: QueryCtx,
tenantId: string,
scopes: string[]
): Promise<TicketFieldScopeMap> {
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<Doc<"ticketFormSettings">[]> {
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<Doc<"ticketFormSettings">[]>([]);
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) { async function ensureTicketFormDefaultsForTenant(ctx: MutationCtx, tenantId: string) {
await ensureTicketFormTemplatesForTenant(ctx, tenantId); await ensureTicketFormTemplatesForTenant(ctx, tenantId);
const now = Date.now(); const now = Date.now();
@ -776,17 +859,11 @@ function coerceCustomFieldValue(field: Doc<"ticketFields">, raw: unknown): { val
return { value }; return { value };
} }
case "date": { case "date": {
if (typeof raw === "number") { const normalized = normalizeDateOnlyValue(raw);
if (!Number.isFinite(raw)) { if (!normalized) {
throw new ConvexError(`Data inválida para o campo ${field.label}`);
}
return { value: raw };
}
const parsed = Date.parse(String(raw));
if (!Number.isFinite(parsed)) {
throw new ConvexError(`Data inválida para o campo ${field.label}`); throw new ConvexError(`Data inválida para o campo ${field.label}`);
} }
return { value: parsed }; return { value: normalized };
} }
case "boolean": { case "boolean": {
if (typeof raw === "boolean") { if (typeof raw === "boolean") {
@ -883,16 +960,59 @@ async function normalizeCustomFieldValues(
function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) { function mapCustomFieldsToRecord(entries: NormalizedCustomField[] | undefined) {
if (!entries || entries.length === 0) return {}; if (!entries || entries.length === 0) return {};
return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => { return entries.reduce<Record<string, { label: string; type: string; value: unknown; displayValue?: string }>>((acc, entry) => {
let value: unknown = entry.value;
if (entry.type === "date") {
value = normalizeDateOnlyValue(entry.value) ?? entry.value;
}
acc[entry.fieldKey] = { acc[entry.fieldKey] = {
label: entry.label, label: entry.label,
type: entry.type, type: entry.type,
value: entry.value, value,
displayValue: entry.displayValue, displayValue: entry.displayValue,
}; };
return acc; 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<string, { label: string; type: string; value: unknown; displayValue?: string }>,
key: string
): CustomFieldRecordEntry {
return Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined;
}
const DEFAULT_TICKETS_LIST_LIMIT = 250; const DEFAULT_TICKETS_LIST_LIMIT = 250;
const MIN_TICKETS_LIST_LIMIT = 25; const MIN_TICKETS_LIST_LIMIT = 25;
const MAX_TICKETS_LIST_LIMIT = 600; const MAX_TICKETS_LIST_LIMIT = 600;
@ -2387,27 +2507,24 @@ export const listTicketForms = query({
handler: async (ctx, { tenantId, viewerId, companyId }) => { handler: async (ctx, { tenantId, viewerId, companyId }) => {
const viewer = await requireUser(ctx, viewerId, tenantId) const viewer = await requireUser(ctx, viewerId, tenantId)
const viewerCompanyId = companyId ?? viewer.user.companyId ?? null const viewerCompanyId = companyId ?? viewer.user.companyId ?? null
const viewerRole = (viewer.role ?? "").toUpperCase()
const templates = await fetchTemplateSummaries(ctx, tenantId)
const settings = await ctx.db const scopes = templates.map((template) => template.key)
.query("ticketFormSettings") const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const fieldDefinitions = await ctx.db const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
.query("ticketFields") const settingsByTemplate = new Map<string, Doc<"ticketFormSettings">[]>()
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
.collect()
const fieldsByScope = new Map<string, Doc<"ticketFields">[]>() if (!staffOverride) {
for (const definition of fieldDefinitions) { await Promise.all(
const scope = (definition.scope ?? "all").trim() templates.map(async (template) => {
if (!fieldsByScope.has(scope)) { const scopedSettings = await fetchScopedFormSettings(ctx, tenantId, template.key, viewer.user._id, viewerCompanyId)
fieldsByScope.set(scope, []) settingsByTemplate.set(template.key, scopedSettings)
} })
fieldsByScope.get(scope)!.push(definition) )
} }
const templates = await fetchTemplateSummaries(ctx, tenantId)
const forms = [] as Array<{ const forms = [] as Array<{
key: string key: string
label: string label: string
@ -2424,14 +2541,13 @@ export const listTicketForms = query({
}> }>
for (const template of templates) { for (const template of templates) {
let enabled = resolveFormEnabled(template.key, template.defaultEnabled, settings as Doc<"ticketFormSettings">[], { const templateSettings = settingsByTemplate.get(template.key) ?? []
companyId: viewerCompanyId, let enabled = staffOverride
userId: viewer.user._id, ? true
}) : resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, {
const viewerRole = (viewer.role ?? "").toUpperCase() companyId: viewerCompanyId,
if (viewerRole === "ADMIN" || viewerRole === "AGENT") { userId: viewer.user._id,
enabled = true })
}
if (!enabled) { if (!enabled) {
continue continue
} }
@ -3477,6 +3593,9 @@ export const updateCustomFields = mutation({
throw new ConvexError("Somente administradores e agentes podem editar campos personalizados.") 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 const sanitizedInputs: CustomFieldInput[] = fields
.filter((entry) => entry.value !== undefined) .filter((entry) => entry.value !== undefined)
.map((entry) => ({ .map((entry) => ({
@ -3491,6 +3610,40 @@ export const updateCustomFields = mutation({
ticketDoc.formTemplate ?? null 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() const now = Date.now()
await ctx.db.patch(ticketId, { await ctx.db.patch(ticketId, {
@ -3505,18 +3658,28 @@ export const updateCustomFields = mutation({
actorId, actorId,
actorName: viewer.user.name, actorName: viewer.user.name,
actorAvatar: viewer.user.avatarUrl ?? undefined, actorAvatar: viewer.user.avatarUrl ?? undefined,
fields: normalized.map((field) => ({ fields: changedKeys.map((fieldKey) => {
fieldId: field.fieldId, const meta = metaByFieldKey.get(fieldKey)
fieldKey: field.fieldKey, const previous = getCustomFieldRecordEntry(previousRecord, fieldKey)
label: field.label, const next = getCustomFieldRecordEntry(nextRecord, fieldKey)
type: field.type, 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, createdAt: now,
}) })
return { return {
customFields: mapCustomFieldsToRecord(normalized), customFields: nextRecord,
updatedAt: now, updatedAt: now,
} }
}, },

View file

@ -1,5 +1,7 @@
"use client"; "use client";
import "@/lib/toast-patch";
import { ConvexProvider, ConvexReactClient } from "convex/react"; import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react"; import { ReactNode } from "react";
@ -13,4 +15,3 @@ export function ConvexClientProvider({ children }: { children: ReactNode }) {
} }
return <ConvexProvider client={client!}>{children}</ConvexProvider>; return <ConvexProvider client={client!}>{children}</ConvexProvider>;
} }

View file

@ -26,7 +26,7 @@ import { TicketStatusBadge } from "@/components/tickets/status-badge"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { TicketCsatCard } from "@/components/tickets/ticket-csat-card" import { TicketCsatCard } from "@/components/tickets/ticket-csat-card"
import { TicketCustomFieldsList } from "@/components/tickets/ticket-custom-fields" 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<TicketWithDetails["priority"], string> = { const priorityLabel: Record<TicketWithDetails["priority"], string> = {
LOW: "Baixa", LOW: "Baixa",
@ -192,14 +192,59 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
} }
if (event.type === "CUSTOM_FIELDS_UPDATED") { 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) const fields = Array.isArray((payload as { fields?: unknown }).fields)
? ((payload as { fields?: Array<{ label?: string }> }).fields ?? []) ? ((payload as { fields?: FieldPayload[] }).fields ?? [])
: [] : []
const labels = fields const hasValueDetails = fields.some((field) => {
.map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) if (!field) return false
.filter((label) => label.length > 0) return (
const description = Object.prototype.hasOwnProperty.call(field, "previousValue") ||
labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados" 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 { return {
id: event.id, id: event.id,
title: "Campos personalizados", title: "Campos personalizados",

View file

@ -106,12 +106,12 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
}, [initialScore, initialComment, ratedAtTimestamp]) }, [initialScore, initialComment, ratedAtTimestamp])
const effectiveScore = hasSubmitted ? score : hoverScore ?? score const effectiveScore = hasSubmitted ? score : hoverScore ?? score
const viewerIsStaff = viewerRole === "ADMIN" || viewerRole === "AGENT" || viewerRole === "MANAGER" const viewerIsAdmin = viewerRole === "ADMIN"
const staffCanInspect = viewerIsStaff && ticket.status !== "PENDING" const adminCanInspect = viewerIsAdmin && ticket.status !== "PENDING"
const canSubmit = const canSubmit =
Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted) Boolean(viewerId && viewerRole === "COLLABORATOR" && isRequester && isResolved && !hasSubmitted)
const hasRating = hasSubmitted const hasRating = hasSubmitted
const showCard = staffCanInspect || isRequester || hasSubmitted const showCard = adminCanInspect || isRequester
const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt]) const ratedAtRelative = useMemo(() => formatRelative(ratedAt), [ratedAt])
@ -181,7 +181,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
Conte como foi sua experiência com este chamado. Conte como foi sua experiência com este chamado.
</CardDescription> </CardDescription>
</div> </div>
{hasRating ? ( {hasRating && !viewerIsAdmin ? (
<div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700"> <div className="flex items-center gap-1 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">
Obrigado pelo feedback! Obrigado pelo feedback!
</div> </div>
@ -250,7 +250,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
<p className="whitespace-pre-line">{comment}</p> <p className="whitespace-pre-line">{comment}</p>
</div> </div>
) : null} ) : null}
{viewerIsStaff && !hasRating ? ( {viewerIsAdmin && !hasRating ? (
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500"> <p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm text-neutral-500">
Nenhuma avaliação registrada para este chamado até o momento. Nenhuma avaliação registrada para este chamado até o momento.
</p> </p>

View file

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react" import { useEffect, useMemo, useState, type ReactNode } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner" import { toast } from "sonner"
import { format, parseISO } from "date-fns" import { format, parse } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { CalendarIcon, Pencil, X } from "lucide-react" import { CalendarIcon, Pencil, X } from "lucide-react"
@ -35,6 +35,8 @@ type TicketCustomFieldsListProps = {
actionSlot?: ReactNode actionSlot?: ReactNode
} }
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
const DEFAULT_FORM: TicketFormDefinition = { const DEFAULT_FORM: TicketFormDefinition = {
key: "default", key: "default",
label: "Chamado", label: "Chamado",
@ -42,6 +44,35 @@ const DEFAULT_FORM: TicketFormDefinition = {
fields: [], 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( function buildInitialValues(
fields: TicketFormFieldDefinition[], fields: TicketFormFieldDefinition[],
record?: TicketCustomFieldRecord | null record?: TicketCustomFieldRecord | null
@ -61,17 +92,11 @@ function buildInitialValues(
? value ? value
: "" : ""
break break
case "date": case "date": {
if (typeof value === "number") { const isoValue = toIsoDateString(value)
const date = new Date(value) result[field.id] = isoValue
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] = ""
}
break break
}
case "boolean": case "boolean":
if (value === null || value === undefined) { if (value === null || value === undefined) {
result[field.id] = field.required ? false : null result[field.id] = field.required ? false : null
@ -343,8 +368,10 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{hasConfiguredFields ? ( {hasConfiguredFields ? (
<div className="grid gap-4 sm:grid-cols-2"> <div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4">
{selectedForm.fields.map((field) => renderFieldEditor(field))} <div className="grid gap-4 sm:grid-cols-2">
{selectedForm.fields.map((field) => renderFieldEditor(field))}
</div>
</div> </div>
) : ( ) : (
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p> <p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
@ -383,8 +410,8 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3> <h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
</div> </div>
<TicketCustomFieldsList <TicketCustomFieldsList
record={currentFields} record={currentFields}
emptyMessage="Nenhum campo adicional preenchido neste chamado." emptyMessage="Nenhum campo adicional preenchido neste chamado."
actionSlot={editActionSlot} actionSlot={editActionSlot}
/> />
@ -491,23 +518,28 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
} }
if (field.type === "date") { if (field.type === "date") {
const isoValue = toIsoDateString(value)
const parsedDate = isoValue ? parseIsoDate(isoValue) : null
return ( return (
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}> <Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null} {field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel> </FieldLabel>
<Popover open={openCalendarField === field.id} onOpenChange={() => setOpenCalendarField(field.id)}> <Popover
open={openCalendarField === field.id}
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
className={cn( className={cn(
"justify-between rounded-lg border border-slate-300 text-left font-normal", "justify-between rounded-lg border border-slate-300 text-left font-normal",
!value && "text-muted-foreground" !parsedDate && "text-muted-foreground"
)} )}
> >
{value ? ( {parsedDate ? (
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR }) format(parsedDate, "dd/MM/yyyy", { locale: ptBR })
) : ( ) : (
<span>Selecionar data</span> <span>Selecionar data</span>
)} )}
@ -517,7 +549,7 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
<PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md"> <PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md">
<Calendar <Calendar
mode="single" mode="single"
selected={value ? new Date(value as string) : undefined} selected={parsedDate ?? undefined}
onSelect={(date) => { onSelect={(date) => {
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "") handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
setOpenCalendarField(null) setOpenCalendarField(null)
@ -530,6 +562,23 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
) )
} }
if (field.type === "text" && !isTextarea) {
return (
<Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1">
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
</FieldLabel>
<Input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(event) => handleFieldChange(field, event.target.value)}
className="rounded-lg border border-slate-300"
/>
{helpText}
</Field>
)
}
return ( return (
<Field key={field.id} className={spanClass}> <Field key={field.id} className={spanClass}>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">

View file

@ -28,6 +28,7 @@ import {
PaginationPrevious, PaginationPrevious,
} from "@/components/ui/pagination" } from "@/components/ui/pagination"
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels" import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
import { formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = { const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
CREATED: IconUserCircle, CREATED: IconUserCircle,
@ -305,22 +306,87 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
) )
} }
if (entry.type === "CUSTOM_FIELDS_UPDATED") { if (entry.type === "CUSTOM_FIELDS_UPDATED") {
type FieldPayload = {
fieldKey?: string
label?: string
type?: string
previousValue?: unknown
nextValue?: unknown
previousDisplayValue?: string | null
nextDisplayValue?: string | null
changeType?: string
}
const payloadFields = Array.isArray((payload as { fields?: unknown }).fields) const payloadFields = Array.isArray((payload as { fields?: unknown }).fields)
? ((payload as { fields?: Array<{ label?: string }> }).fields ?? []) ? ((payload as { fields?: FieldPayload[] }).fields ?? [])
: [] : []
const fieldLabels = payloadFields const hasValueDetails = payloadFields.some((field) => {
.map((field) => (typeof field?.label === "string" ? field.label.trim() : "")) if (!field) return false
.filter((label) => label.length > 0) return (
message = ( Object.prototype.hasOwnProperty.call(field, "previousValue") ||
<div className="space-y-1"> Object.prototype.hasOwnProperty.call(field, "nextValue") ||
<span className="block text-sm text-neutral-600"> Object.prototype.hasOwnProperty.call(field, "previousDisplayValue") ||
<span className="font-semibold text-neutral-800">Campos personalizados atualizados</span> Object.prototype.hasOwnProperty.call(field, "nextDisplayValue")
{fieldLabels.length > 0 ? ( )
<span className="text-neutral-500"> ({fieldLabels.join(", ")})</span> })
) : null} if (hasValueDetails && payloadFields.length > 0) {
</span> message = (
</div> <div className="space-y-1">
) <span className="block text-sm font-semibold text-neutral-800">
Campos personalizados atualizados
</span>
<ul className="mt-1 space-y-0.5 text-xs text-neutral-600">
{payloadFields.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 previousFormatted = formatTicketCustomFieldValue({
type: baseType,
value: field?.previousValue,
displayValue:
typeof field?.previousDisplayValue === "string" && field.previousDisplayValue.trim().length > 0
? field.previousDisplayValue
: undefined,
})
const nextFormatted = formatTicketCustomFieldValue({
type: baseType,
value: field?.nextValue,
displayValue:
typeof field?.nextDisplayValue === "string" && field.nextDisplayValue.trim().length > 0
? field.nextDisplayValue
: undefined,
})
return (
<li key={`${entry.id}-${field?.fieldKey ?? label}`}>
<span className="font-semibold text-neutral-800">{label}</span>{" "}
<span className="text-neutral-500">
{previousFormatted} {nextFormatted}
</span>
</li>
)
})}
</ul>
</div>
)
} else {
const fieldLabels = payloadFields
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
.filter((label) => label.length > 0)
message = (
<div className="space-y-1">
<span className="block text-sm text-neutral-600">
<span className="font-semibold text-neutral-800">Campos personalizados atualizados</span>
{fieldLabels.length > 0 ? (
<span className="text-neutral-500"> ({fieldLabels.join(", ")})</span>
) : null}
</span>
</div>
)
}
} }
if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) { if (entry.type === "STATUS_CHANGED" && (payload.toLabel || payload.to)) {
message = "Status alterado para " + (payload.toLabel || payload.to) message = "Status alterado para " + (payload.toLabel || payload.to)

View file

@ -20,6 +20,36 @@ function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
return normalized ?? "PENDING"; return normalized ?? "PENDING";
} }
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/;
function normalizeCustomFieldDateValue(raw: unknown): string | null {
if (raw === null || raw === undefined) {
return null;
}
if (typeof raw === "string") {
const trimmed = raw.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 =
raw instanceof Date
? raw
: typeof raw === "number"
? new Date(raw)
: null;
if (!date || Number.isNaN(date.getTime())) {
return null;
}
return date.toISOString().slice(0, 10);
}
// Server shapes: datas como number (epoch ms) e alguns nullables // Server shapes: datas como number (epoch ms) e alguns nullables
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app. // Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
const serverUserSchema = z.object({ const serverUserSchema = z.object({
@ -246,8 +276,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
>( >(
(acc, [key, value]) => { (acc, [key, value]) => {
let parsedValue: unknown = value.value; let parsedValue: unknown = value.value;
if (value.type === "date" && typeof value.value === "number") { if (value.type === "date") {
parsedValue = new Date(value.value); parsedValue = normalizeCustomFieldDateValue(value.value) ?? value.value;
} }
acc[key] = { acc[key] = {
label: value.label, label: value.label,

View file

@ -1,4 +1,4 @@
import { format } from "date-fns" import { format, parse } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
export type TicketCustomFieldRecord = Record< export type TicketCustomFieldRecord = Record<
@ -25,18 +25,41 @@ function formatBoolean(value: unknown): string {
return value === true || String(value).toLowerCase() === "true" ? "Sim" : "Não" return value === true || String(value).toLowerCase() === "true" ? "Sim" : "Não"
} }
const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/
function normalizeIsoDate(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" && Number.isFinite(value)
? new Date(value)
: null
if (!date || Number.isNaN(date.getTime())) {
return null
}
return date.toISOString().slice(0, 10)
}
function formatDate(value: unknown): string { function formatDate(value: unknown): string {
if (value === null || value === undefined) return "—" if (value === null || value === undefined) return "—"
const date = const iso = normalizeIsoDate(value)
typeof value === "number" if (!iso) return "—"
? Number.isFinite(value) const parsed = parse(iso, "yyyy-MM-dd", new Date())
? new Date(value) if (Number.isNaN(parsed.getTime())) return "—"
: null return format(parsed, "dd/MM/yyyy", { locale: ptBR })
: typeof value === "string" && value.trim().length > 0
? new Date(value)
: null
if (!date || Number.isNaN(date.getTime())) return "—"
return format(date, "dd/MM/yyyy", { locale: ptBR })
} }
function formatNumber(value: unknown): string { function formatNumber(value: unknown): string {

71
src/lib/toast-patch.ts Normal file
View file

@ -0,0 +1,71 @@
"use client"
import { toast } from "sonner"
const METHODS = ["success", "error", "info", "warning", "message", "loading"] as const
const TRAILING_PUNCTUATION_REGEX = /[\s!?.,;:]+$/u
const toastAny = toast as unknown as Record<string, (...args: any[]) => unknown> & { __punctuationPatched?: boolean }
function stripTrailingPunctuation(value: string): string {
const trimmed = value.trimEnd()
if (!trimmed) return trimmed
return trimmed.replace(TRAILING_PUNCTUATION_REGEX, "").trimEnd()
}
function sanitizeContent<T>(value: T): T {
if (typeof value === "string") {
return stripTrailingPunctuation(value) as T
}
return value
}
function sanitizeOptions(options: unknown): unknown {
if (!options || typeof options !== "object") return options
if ("description" in options && typeof (options as { description?: unknown }).description === "string") {
return {
...(options as Record<string, unknown>),
description: stripTrailingPunctuation((options as { description: string }).description),
}
}
return options
}
function wrapSimpleMethod(method: (typeof METHODS)[number]) {
const original = toastAny[method]
if (typeof original !== "function") return
toastAny[method] = (message: unknown, options?: unknown, ...rest: unknown[]) => {
return original(sanitizeContent(message), sanitizeOptions(options), ...rest)
}
}
function wrapPromise() {
const originalPromise = toastAny.promise
if (typeof originalPromise !== "function") return
toastAny.promise = (promise: Promise<unknown>, messages: Record<string, unknown>, options?: unknown) => {
const wrapMessage = (value: unknown) => {
if (typeof value === "function") {
return (...args: unknown[]) => sanitizeContent((value as (...args: unknown[]) => unknown)(...args))
}
return sanitizeContent(value)
}
const normalizedMessages =
messages && typeof messages === "object"
? {
...messages,
loading: wrapMessage(messages.loading),
success: wrapMessage(messages.success),
error: wrapMessage(messages.error),
finally: wrapMessage((messages as { finally?: unknown }).finally),
}
: messages
return originalPromise(promise, normalizedMessages, sanitizeOptions(options))
}
}
if (!toastAny.__punctuationPatched) {
toastAny.__punctuationPatched = true
METHODS.forEach(wrapSimpleMethod)
wrapPromise()
}