Improve custom field timeline and toasts
This commit is contained in:
parent
f7aa17f229
commit
a2f9d4bd1a
9 changed files with 549 additions and 101 deletions
|
|
@ -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.
|
||||
</CardDescription>
|
||||
</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">
|
||||
Obrigado pelo feedback!
|
||||
</div>
|
||||
|
|
@ -250,7 +250,7 @@ export function TicketCsatCard({ ticket }: TicketCsatCardProps) {
|
|||
<p className="whitespace-pre-line">{comment}</p>
|
||||
</div>
|
||||
) : 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">
|
||||
Nenhuma avaliação registrada para este chamado até o momento.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{hasConfiguredFields ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white/70 px-4 py-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
|
||||
</div>
|
||||
<TicketCustomFieldsList
|
||||
record={currentFields}
|
||||
<TicketCustomFieldsList
|
||||
record={currentFields}
|
||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||
actionSlot={editActionSlot}
|
||||
/>
|
||||
|
|
@ -491,23 +518,28 @@ function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const isoValue = toIsoDateString(value)
|
||||
const parsedDate = isoValue ? parseIsoDate(isoValue) : null
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex flex-col gap-2", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Popover open={openCalendarField === field.id} onOpenChange={() => setOpenCalendarField(field.id)}>
|
||||
<Popover
|
||||
open={openCalendarField === field.id}
|
||||
onOpenChange={(open) => setOpenCalendarField(open ? field.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"justify-between rounded-lg border border-slate-300 text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
!parsedDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{value ? (
|
||||
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR })
|
||||
{parsedDate ? (
|
||||
format(parsedDate, "dd/MM/yyyy", { locale: ptBR })
|
||||
) : (
|
||||
<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">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value ? new Date(value as string) : undefined}
|
||||
selected={parsedDate ?? undefined}
|
||||
onSelect={(date) => {
|
||||
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
||||
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 (
|
||||
<Field key={field.id} className={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
import { TICKET_TIMELINE_LABELS } from "@/lib/ticket-timeline-labels"
|
||||
import { formatTicketCustomFieldValue } from "@/lib/ticket-custom-fields"
|
||||
|
||||
const timelineIcons: Record<string, ComponentType<{ className?: string }>> = {
|
||||
CREATED: IconUserCircle,
|
||||
|
|
@ -305,22 +306,87 @@ export function TicketTimeline({ ticket }: TicketTimelineProps) {
|
|||
)
|
||||
}
|
||||
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)
|
||||
? ((payload as { fields?: Array<{ label?: string }> }).fields ?? [])
|
||||
? ((payload as { fields?: FieldPayload[] }).fields ?? [])
|
||||
: []
|
||||
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>
|
||||
)
|
||||
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
|
||||
.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)) {
|
||||
message = "Status alterado para " + (payload.toLabel || payload.to)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue