Improve custom field timeline and toasts
This commit is contained in:
parent
f7aa17f229
commit
a2f9d4bd1a
9 changed files with 549 additions and 101 deletions
|
|
@ -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}`);
|
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
||||||
}
|
}
|
||||||
return { value: raw };
|
return { value: normalized };
|
||||||
}
|
|
||||||
const parsed = Date.parse(String(raw));
|
|
||||||
if (!Number.isFinite(parsed)) {
|
|
||||||
throw new ConvexError(`Data inválida para o campo ${field.label}`);
|
|
||||||
}
|
|
||||||
return { value: parsed };
|
|
||||||
}
|
}
|
||||||
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 settings = await ctx.db
|
|
||||||
.query("ticketFormSettings")
|
|
||||||
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
||||||
.collect()
|
|
||||||
|
|
||||||
const fieldDefinitions = await ctx.db
|
|
||||||
.query("ticketFields")
|
|
||||||
.withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId))
|
|
||||||
.collect()
|
|
||||||
|
|
||||||
const fieldsByScope = new Map<string, Doc<"ticketFields">[]>()
|
|
||||||
for (const definition of fieldDefinitions) {
|
|
||||||
const scope = (definition.scope ?? "all").trim()
|
|
||||||
if (!fieldsByScope.has(scope)) {
|
|
||||||
fieldsByScope.set(scope, [])
|
|
||||||
}
|
|
||||||
fieldsByScope.get(scope)!.push(definition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const templates = await fetchTemplateSummaries(ctx, tenantId)
|
const templates = await fetchTemplateSummaries(ctx, tenantId)
|
||||||
|
|
||||||
|
const scopes = templates.map((template) => template.key)
|
||||||
|
const fieldsByScope = await fetchTicketFieldsByScopes(ctx, tenantId, scopes)
|
||||||
|
|
||||||
|
const staffOverride = viewerRole === "ADMIN" || viewerRole === "AGENT"
|
||||||
|
const settingsByTemplate = new Map<string, Doc<"ticketFormSettings">[]>()
|
||||||
|
|
||||||
|
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 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) ?? []
|
||||||
|
let enabled = staffOverride
|
||||||
|
? true
|
||||||
|
: resolveFormEnabled(template.key, template.defaultEnabled, templateSettings, {
|
||||||
companyId: viewerCompanyId,
|
companyId: viewerCompanyId,
|
||||||
userId: viewer.user._id,
|
userId: viewer.user._id,
|
||||||
})
|
})
|
||||||
const viewerRole = (viewer.role ?? "").toUpperCase()
|
|
||||||
if (viewerRole === "ADMIN" || viewerRole === "AGENT") {
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
const labels = fields
|
||||||
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
|
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
|
||||||
.filter((label) => label.length > 0)
|
.filter((label) => label.length > 0)
|
||||||
const description =
|
description =
|
||||||
labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados"
|
labels.length > 0 ? `Campos atualizados: ${labels.join(", ")}` : "Campos personalizados atualizados"
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
title: "Campos personalizados",
|
title: "Campos personalizados",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,9 +368,11 @@ export function TicketCustomFieldsSection({ ticket, variant = "card", className
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{hasConfiguredFields ? (
|
{hasConfiguredFields ? (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,9 +306,73 @@ 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 hasValueDetails = payloadFields.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")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (hasValueDetails && payloadFields.length > 0) {
|
||||||
|
message = (
|
||||||
|
<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
|
const fieldLabels = payloadFields
|
||||||
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
|
.map((field) => (typeof field?.label === "string" ? field.label.trim() : ""))
|
||||||
.filter((label) => label.length > 0)
|
.filter((label) => label.length > 0)
|
||||||
|
|
@ -322,6 +387,7 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
||||||
</div>
|
</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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
71
src/lib/toast-patch.ts
Normal 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()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue