600 lines
20 KiB
TypeScript
600 lines
20 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
import { format, parse } from "date-fns"
|
|
import { ptBR } from "date-fns/locale"
|
|
import { CalendarIcon, Pencil, X } from "lucide-react"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import type { TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
|
import { mapTicketCustomFields, type TicketCustomFieldRecord } from "@/lib/ticket-custom-fields"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { cn } from "@/lib/utils"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover"
|
|
import { Calendar } from "@/components/ui/calendar"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
import { Field, FieldLabel } from "@/components/ui/field"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { useLocalTimeZone } from "@/hooks/use-local-time-zone"
|
|
|
|
type TicketCustomFieldsListProps = {
|
|
record?: TicketCustomFieldRecord | null
|
|
emptyMessage?: string
|
|
className?: string
|
|
actionSlot?: ReactNode
|
|
}
|
|
|
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
|
|
|
const DEFAULT_FORM: TicketFormDefinition = {
|
|
key: "default",
|
|
label: "Chamado",
|
|
description: "",
|
|
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
|
|
): Record<string, unknown> {
|
|
if (!record) return {}
|
|
const result: Record<string, unknown> = {}
|
|
for (const field of fields) {
|
|
const entry = record[field.key]
|
|
if (!entry) continue
|
|
const value = entry.value
|
|
switch (field.type) {
|
|
case "number":
|
|
result[field.id] =
|
|
typeof value === "number"
|
|
? String(value)
|
|
: typeof value === "string"
|
|
? value
|
|
: ""
|
|
break
|
|
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
|
|
} else {
|
|
result[field.id] = Boolean(value)
|
|
}
|
|
break
|
|
default:
|
|
result[field.id] = value ?? ""
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
function isEmptyValue(value: unknown): boolean {
|
|
if (value === undefined || value === null) return true
|
|
if (typeof value === "string" && value.trim().length === 0) return true
|
|
return false
|
|
}
|
|
|
|
function normalizeFieldValue(
|
|
field: TicketFormFieldDefinition,
|
|
raw: unknown
|
|
): { ok: true; value: unknown } | { ok: false; message: string } | { ok: true; skip: true } {
|
|
if (field.type === "boolean") {
|
|
if (raw === null || raw === undefined) {
|
|
if (field.required) {
|
|
return { ok: false, message: `Preencha o campo "${field.label}".` }
|
|
}
|
|
return { ok: true, skip: true }
|
|
}
|
|
return { ok: true, value: Boolean(raw) }
|
|
}
|
|
|
|
if (isEmptyValue(raw)) {
|
|
if (field.required) {
|
|
return { ok: false, message: `Preencha o campo "${field.label}".` }
|
|
}
|
|
return { ok: true, skip: true }
|
|
}
|
|
|
|
switch (field.type) {
|
|
case "number": {
|
|
const numeric = typeof raw === "number" ? raw : Number(String(raw).replace(",", "."))
|
|
if (!Number.isFinite(numeric)) {
|
|
return { ok: false, message: `Informe um valor numérico válido para "${field.label}".` }
|
|
}
|
|
return { ok: true, value: numeric }
|
|
}
|
|
case "date": {
|
|
const value = String(raw)
|
|
if (!value || Number.isNaN(Date.parse(value))) {
|
|
return { ok: false, message: `Selecione uma data válida para "${field.label}".` }
|
|
}
|
|
return { ok: true, value }
|
|
}
|
|
case "select": {
|
|
const value = String(raw)
|
|
if (!value) {
|
|
if (field.required) {
|
|
return { ok: false, message: `Selecione uma opção para "${field.label}".` }
|
|
}
|
|
return { ok: true, skip: true }
|
|
}
|
|
return { ok: true, value }
|
|
}
|
|
default: {
|
|
if (typeof raw === "string") {
|
|
return { ok: true, value: raw.trim() }
|
|
}
|
|
return { ok: true, value: raw }
|
|
}
|
|
}
|
|
}
|
|
|
|
export function TicketCustomFieldsList({ record, emptyMessage, className, actionSlot }: TicketCustomFieldsListProps) {
|
|
const entries = useMemo(() => mapTicketCustomFields(record), [record])
|
|
const hasAction = Boolean(actionSlot)
|
|
|
|
if (entries.length === 0) {
|
|
return (
|
|
<div className={cn("space-y-3", className)}>
|
|
{hasAction ? (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{actionSlot}
|
|
</div>
|
|
) : null}
|
|
<p className="text-sm text-neutral-500">
|
|
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
|
{hasAction ? actionSlot : null}
|
|
{entries.map((entry) => (
|
|
<div
|
|
key={entry.key}
|
|
className="rounded-2xl border border-slate-200 bg-white px-4 py-3 shadow-sm"
|
|
>
|
|
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">{entry.label}</p>
|
|
<p className="mt-1 text-sm font-semibold text-neutral-900">{entry.formattedValue}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TicketCustomFieldsSectionProps = {
|
|
ticket: TicketWithDetails
|
|
variant?: "card" | "inline"
|
|
className?: string
|
|
}
|
|
|
|
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
|
|
const { convexUserId, role } = useAuth()
|
|
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
|
const calendarTimeZone = useLocalTimeZone()
|
|
|
|
const viewerId = convexUserId as Id<"users"> | null
|
|
const tenantId = ticket.tenantId
|
|
const ticketCompanyId = ticket.company?.id ?? null
|
|
|
|
const ensureTicketFormDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
|
|
|
|
useEffect(() => {
|
|
if (!canEdit || !viewerId) return
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
await ensureTicketFormDefaults({
|
|
tenantId,
|
|
actorId: viewerId,
|
|
})
|
|
} catch (error) {
|
|
if (!cancelled) {
|
|
console.error("[ticket-custom-fields] Falha ao garantir campos padrão", error)
|
|
}
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [canEdit, ensureTicketFormDefaults, tenantId, viewerId])
|
|
|
|
const formsRemote = useQuery(
|
|
api.tickets.listTicketForms,
|
|
canEdit && viewerId
|
|
? { tenantId, viewerId, companyId: ticketCompanyId ? (ticketCompanyId as Id<"companies">) : undefined }
|
|
: "skip"
|
|
) as TicketFormDefinition[] | undefined
|
|
|
|
const availableForms = useMemo<TicketFormDefinition[]>(() => {
|
|
if (!formsRemote || formsRemote.length === 0) {
|
|
return [DEFAULT_FORM]
|
|
}
|
|
return formsRemote
|
|
}, [formsRemote])
|
|
|
|
const selectedForm = useMemo<TicketFormDefinition>(() => {
|
|
const key = ticket.formTemplate ?? "default"
|
|
return availableForms.find((form) => form.key === key) ?? availableForms[0] ?? DEFAULT_FORM
|
|
}, [availableForms, ticket.formTemplate])
|
|
|
|
const [editorOpen, setEditorOpen] = useState(false)
|
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({})
|
|
const [openCalendarField, setOpenCalendarField] = useState<string | null>(null)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [validationError, setValidationError] = useState<string | null>(null)
|
|
const [currentFields, setCurrentFields] = useState<TicketCustomFieldRecord | null | undefined>(ticket.customFields)
|
|
|
|
useEffect(() => {
|
|
setCurrentFields(ticket.customFields)
|
|
}, [ticket.customFields])
|
|
|
|
const initialValues = useMemo(
|
|
() => buildInitialValues(selectedForm.fields, currentFields),
|
|
[selectedForm.fields, currentFields]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!editorOpen) return
|
|
setCustomFieldValues(initialValues)
|
|
setValidationError(null)
|
|
}, [editorOpen, initialValues])
|
|
|
|
const updateCustomFields = useMutation(api.tickets.updateCustomFields)
|
|
|
|
const handleFieldChange = (field: TicketFormFieldDefinition, value: unknown) => {
|
|
setCustomFieldValues((previous) => ({
|
|
...previous,
|
|
[field.id]: value,
|
|
}))
|
|
}
|
|
|
|
const handleClearField = (fieldId: string) => {
|
|
setCustomFieldValues((previous) => {
|
|
const next = { ...previous }
|
|
delete next[fieldId]
|
|
return next
|
|
})
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
if (!viewerId) return
|
|
const payload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
|
|
|
|
for (const field of selectedForm.fields) {
|
|
const raw = customFieldValues[field.id]
|
|
const normalized = normalizeFieldValue(field, raw)
|
|
if (!normalized.ok) {
|
|
if ("message" in normalized) {
|
|
setValidationError(normalized.message)
|
|
}
|
|
return
|
|
}
|
|
if ("skip" in normalized && normalized.skip) {
|
|
continue
|
|
}
|
|
payload.push({
|
|
fieldId: field.id as Id<"ticketFields">,
|
|
value: "value" in normalized ? normalized.value : null,
|
|
})
|
|
}
|
|
|
|
setIsSaving(true)
|
|
setValidationError(null)
|
|
try {
|
|
const result = await updateCustomFields({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId: viewerId,
|
|
fields: payload,
|
|
})
|
|
setCurrentFields(result?.customFields ?? currentFields)
|
|
toast.success("Campos personalizados atualizados!", { id: "ticket-custom-fields" })
|
|
setEditorOpen(false)
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível atualizar os campos personalizados.", { id: "ticket-custom-fields" })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
const hasConfiguredFields = selectedForm.fields.length > 0
|
|
|
|
const editActionSlot =
|
|
canEdit && hasConfiguredFields ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditorOpen(true)}
|
|
className="flex h-full min-h-[88px] w-full flex-col items-start justify-center gap-1 rounded-2xl border-2 border-dashed border-slate-300 bg-slate-50/60 px-4 py-3 text-left text-sm font-semibold text-neutral-700 transition hover:border-slate-400 hover:bg-white"
|
|
>
|
|
<span className="inline-flex items-center gap-2 text-sm font-semibold text-neutral-900">
|
|
<Pencil className="size-3.5 text-neutral-500" />
|
|
Editar campos
|
|
</span>
|
|
<span className="text-xs font-medium text-neutral-500">Atualize informações personalizadas deste ticket.</span>
|
|
</button>
|
|
) : null
|
|
|
|
const dialog = (
|
|
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
|
<DialogContent className="max-w-3xl gap-4">
|
|
<DialogHeader>
|
|
<DialogTitle>Editar campos personalizados</DialogTitle>
|
|
<DialogDescription>
|
|
Atualize os campos adicionais deste chamado. Os campos obrigatórios devem ser preenchidos.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{hasConfiguredFields ? (
|
|
<div className="grid gap-4 rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:grid-cols-2">
|
|
<p className="text-sm font-semibold text-neutral-800 sm:col-span-2">Informações adicionais</p>
|
|
{selectedForm.fields.map((field) => renderFieldEditor(field))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-neutral-500">Nenhum campo configurado ainda.</p>
|
|
)}
|
|
{validationError ? <p className="text-sm font-semibold text-rose-600">{validationError}</p> : null}
|
|
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
|
|
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)} disabled={isSaving}>
|
|
Cancelar
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={isSaving}>
|
|
{isSaving ? "Salvando..." : "Salvar alterações"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
|
|
const listClassName = cn(variant === "inline" ? "sm:col-span-2 lg:col-span-3" : "", className)
|
|
|
|
if (variant === "inline") {
|
|
return (
|
|
<>
|
|
<TicketCustomFieldsList
|
|
record={currentFields}
|
|
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
|
actionSlot={editActionSlot}
|
|
className={listClassName}
|
|
/>
|
|
{dialog}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<section className={cn("space-y-3", 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}
|
|
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
|
actionSlot={editActionSlot}
|
|
/>
|
|
{dialog}
|
|
</section>
|
|
)
|
|
|
|
function renderFieldEditor(field: TicketFormFieldDefinition) {
|
|
const value = customFieldValues[field.id]
|
|
const fieldId = `ticket-custom-field-${field.id}`
|
|
const isTextarea =
|
|
field.type === "text" && (field.key.includes("observacao") || field.key.includes("permissao"))
|
|
const spanClass =
|
|
field.type === "boolean" || field.type === "date" || isTextarea ? "sm:col-span-2" : ""
|
|
const helpText = field.description ? (
|
|
<p className="text-xs text-neutral-500">{field.description}</p>
|
|
) : null
|
|
|
|
if (field.type === "boolean") {
|
|
const isIndeterminate = value === null || value === undefined
|
|
return (
|
|
<div
|
|
key={field.id}
|
|
className={cn(
|
|
"flex items-start gap-3 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2",
|
|
spanClass
|
|
)}
|
|
>
|
|
<input
|
|
id={fieldId}
|
|
type="checkbox"
|
|
className="mt-1 size-4 rounded border border-slate-300 text-[#00d6eb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#00d6eb]/40"
|
|
checked={Boolean(value)}
|
|
ref={(element) => {
|
|
if (!element) return
|
|
element.indeterminate = isIndeterminate
|
|
}}
|
|
onChange={(event) => handleFieldChange(field, event.target.checked)}
|
|
/>
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
<label htmlFor={fieldId} className="text-sm font-medium text-neutral-800">
|
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
|
</label>
|
|
{helpText}
|
|
{!field.required ? (
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center gap-1 text-xs font-medium text-neutral-500 transition hover:text-neutral-700"
|
|
onClick={() => handleClearField(field.id)}
|
|
>
|
|
<X className="size-3" />
|
|
Remover valor
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (field.type === "select") {
|
|
return (
|
|
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
|
<FieldLabel className="flex items-center gap-1">
|
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
|
</FieldLabel>
|
|
<Select value={typeof value === "string" ? value : ""} onValueChange={(selected) => handleFieldChange(field, selected)}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Selecione" />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60 overflow-y-auto rounded-xl border border-slate-200 bg-white text-neutral-800 shadow-md">
|
|
{!field.required ? (
|
|
<SelectItem value="" className="text-neutral-500">
|
|
Limpar seleção
|
|
</SelectItem>
|
|
) : null}
|
|
{field.options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{helpText}
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
if (field.type === "number") {
|
|
return (
|
|
<Field key={field.id} className={cn("gap-1.5", spanClass)}>
|
|
<FieldLabel className="flex items-center gap-1">
|
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
|
</FieldLabel>
|
|
<Input
|
|
type="number"
|
|
value={typeof value === "number" || typeof value === "string" ? String(value) : ""}
|
|
onChange={(event) => handleFieldChange(field, event.target.value)}
|
|
placeholder="0"
|
|
className="rounded-lg border border-slate-300"
|
|
/>
|
|
{helpText}
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
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-1.5", 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={(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",
|
|
!parsedDate && "text-muted-foreground"
|
|
)}
|
|
>
|
|
{parsedDate ? (
|
|
format(parsedDate, "dd/MM/yyyy", { locale: ptBR })
|
|
) : (
|
|
<span>Selecionar data</span>
|
|
)}
|
|
<CalendarIcon className="ml-2 size-4 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-auto rounded-xl border border-slate-200 bg-white p-0 shadow-md">
|
|
<Calendar
|
|
mode="single"
|
|
selected={parsedDate ?? undefined}
|
|
onSelect={(date) => {
|
|
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
|
setOpenCalendarField(null)
|
|
}}
|
|
timeZone={calendarTimeZone}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
{helpText}
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
if (field.type === "text" && !isTextarea) {
|
|
return (
|
|
<Field key={field.id} className={cn("gap-1.5", 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={cn("gap-1.5", spanClass)}>
|
|
<FieldLabel className="flex items-center gap-1">
|
|
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
|
</FieldLabel>
|
|
<Textarea
|
|
value={typeof value === "string" ? value : ""}
|
|
onChange={(event) => handleFieldChange(field, event.target.value)}
|
|
className="min-h-[88px] resize-none rounded-lg border border-slate-300"
|
|
/>
|
|
{helpText}
|
|
</Field>
|
|
)
|
|
}
|
|
|
|
}
|