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

@ -20,6 +20,36 @@ function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
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
// Relaxamos email/urls no shape do servidor para evitar que payloads parciais quebrem o app.
const serverUserSchema = z.object({
@ -246,8 +276,8 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
>(
(acc, [key, value]) => {
let parsedValue: unknown = value.value;
if (value.type === "date" && typeof value.value === "number") {
parsedValue = new Date(value.value);
if (value.type === "date") {
parsedValue = normalizeCustomFieldDateValue(value.value) ?? value.value;
}
acc[key] = {
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"
export type TicketCustomFieldRecord = Record<
@ -25,18 +25,41 @@ function formatBoolean(value: unknown): string {
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 {
if (value === null || value === undefined) return "—"
const date =
typeof value === "number"
? Number.isFinite(value)
? new Date(value)
: null
: 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 })
const iso = normalizeIsoDate(value)
if (!iso) return "—"
const parsed = parse(iso, "yyyy-MM-dd", new Date())
if (Number.isNaN(parsed.getTime())) return "—"
return format(parsed, "dd/MM/yyyy", { locale: ptBR })
}
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()
}