Atualiza dashboards e painel de tickets
This commit is contained in:
parent
c66ffa6e0b
commit
4655c7570a
9 changed files with 483 additions and 420 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useMutation, useQuery } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { format, parseISO } from "date-fns"
|
||||
|
|
@ -32,6 +32,7 @@ type TicketCustomFieldsListProps = {
|
|||
record?: TicketCustomFieldRecord | null
|
||||
emptyMessage?: string
|
||||
className?: string
|
||||
actionSlot?: ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: TicketFormDefinition = {
|
||||
|
|
@ -146,19 +147,28 @@ function normalizeFieldValue(
|
|||
}
|
||||
}
|
||||
|
||||
export function TicketCustomFieldsList({ record, emptyMessage, className }: TicketCustomFieldsListProps) {
|
||||
export function TicketCustomFieldsList({ record, emptyMessage, className, actionSlot }: TicketCustomFieldsListProps) {
|
||||
const entries = useMemo(() => mapTicketCustomFields(record), [record])
|
||||
const hasAction = Boolean(actionSlot)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<p className={cn("text-sm text-neutral-500", className)}>
|
||||
{emptyMessage ?? "Nenhum campo adicional preenchido neste chamado."}
|
||||
</p>
|
||||
<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}
|
||||
|
|
@ -174,9 +184,11 @@ export function TicketCustomFieldsList({ record, emptyMessage, className }: Tick
|
|||
|
||||
type TicketCustomFieldsSectionProps = {
|
||||
ticket: TicketWithDetails
|
||||
variant?: "card" | "inline"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionProps) {
|
||||
export function TicketCustomFieldsSection({ ticket, variant = "card", className }: TicketCustomFieldsSectionProps) {
|
||||
const { convexUserId, role } = useAuth()
|
||||
const canEdit = Boolean(convexUserId && (role === "admin" || role === "agent"))
|
||||
|
||||
|
|
@ -229,10 +241,15 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
|||
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, ticket.customFields),
|
||||
[selectedForm.fields, ticket.customFields]
|
||||
() => buildInitialValues(selectedForm.fields, currentFields),
|
||||
[selectedForm.fields, currentFields]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -283,11 +300,12 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
|||
setIsSaving(true)
|
||||
setValidationError(null)
|
||||
try {
|
||||
await updateCustomFields({
|
||||
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) {
|
||||
|
|
@ -298,281 +316,233 @@ export function TicketCustomFieldsSection({ ticket }: TicketCustomFieldsSectionP
|
|||
}
|
||||
}
|
||||
|
||||
const entries = useMemo(() => mapTicketCustomFields(ticket.customFields), [ticket.customFields])
|
||||
const hasConfiguredFields = selectedForm.fields.length > 0
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Informações adicionais</h3>
|
||||
{canEdit && hasConfiguredFields ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
Editar campos
|
||||
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 sm:grid-cols-2">
|
||||
{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>
|
||||
) : null}
|
||||
<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={ticket.customFields}
|
||||
<TicketCustomFieldsList
|
||||
record={currentFields}
|
||||
emptyMessage="Nenhum campo adicional preenchido neste chamado."
|
||||
actionSlot={editActionSlot}
|
||||
/>
|
||||
{dialog}
|
||||
</section>
|
||||
)
|
||||
|
||||
<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>
|
||||
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
|
||||
|
||||
{hasConfiguredFields ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{selectedForm.fields.map((field) => {
|
||||
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 =
|
||||
isTextarea || field.type === "boolean" || field.type === "date" ? "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={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={spanClass}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={fieldId}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={
|
||||
typeof value === "number"
|
||||
? String(value)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: ""
|
||||
}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === "date") {
|
||||
const parsedDate =
|
||||
typeof value === "string" && value
|
||||
? parseISO(value)
|
||||
: undefined
|
||||
const isValidDate = Boolean(parsedDate && !Number.isNaN(parsedDate.getTime()))
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex flex-col gap-1", 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(
|
||||
"w-full justify-between gap-2 text-left font-normal",
|
||||
!isValidDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isValidDate
|
||||
? format(parsedDate as Date, "dd/MM/yyyy", { locale: ptBR })
|
||||
: "Selecionar data"}
|
||||
</span>
|
||||
<CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={isValidDate ? (parsedDate as Date) : undefined}
|
||||
onSelect={(selected) => {
|
||||
handleFieldChange(
|
||||
field,
|
||||
selected ? format(selected, "yyyy-MM-dd") : ""
|
||||
)
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
initialFocus
|
||||
captionLayout="dropdown"
|
||||
startMonth={new Date(1900, 0)}
|
||||
endMonth={new Date(new Date().getFullYear() + 5, 11)}
|
||||
locale={ptBR}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!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={() => handleFieldChange(field, "")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
Limpar data
|
||||
</button>
|
||||
) : null}
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTextarea) {
|
||||
return (
|
||||
<Field key={field.id} className={cn("flex-col", spanClass)}>
|
||||
<FieldLabel className="flex items-center gap-1">
|
||||
{field.label} {field.required ? <span className="text-destructive">*</span> : null}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
className="min-h-[90px]"
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
id={fieldId}
|
||||
value={
|
||||
typeof value === "string"
|
||||
? value
|
||||
: typeof value === "number"
|
||||
? String(value)
|
||||
: value != null
|
||||
? String(value)
|
||||
: ""
|
||||
}
|
||||
onChange={(event) => handleFieldChange(field, event.target.value)}
|
||||
/>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-neutral-600">
|
||||
Nenhum campo personalizado configurado para este formulário.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationError ? (
|
||||
<p className="text-sm font-medium text-destructive">{validationError}</p>
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
if (field.type === "select") {
|
||||
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>
|
||||
<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={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") {
|
||||
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)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
"justify-between rounded-lg border border-slate-300 text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
Cancelar
|
||||
{value ? (
|
||||
format(new Date(value as string), "dd/MM/yyyy", { locale: ptBR })
|
||||
) : (
|
||||
<span>Selecionar data</span>
|
||||
)}
|
||||
<CalendarIcon className="ml-2 size-4 opacity-50" />
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSubmit} disabled={isSaving || !hasConfiguredFields}>
|
||||
{isSaving ? <Spinner className="size-4" /> : null}
|
||||
Salvar alterações
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
</PopoverTrigger>
|
||||
<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}
|
||||
onSelect={(date) => {
|
||||
handleFieldChange(field, date ? format(date, "yyyy-MM-dd") : "")
|
||||
setOpenCalendarField(null)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{helpText}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,12 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { getTicketStatusLabel, getTicketStatusSummaryTone } from "@/lib/ticket-status-style"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||
|
||||
interface TicketDetailsPanelProps {
|
||||
ticket: TicketWithDetails
|
||||
}
|
||||
|
||||
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger"
|
||||
type SummaryTone = "default" | "info" | "warning" | "success" | "muted" | "danger" | "primary"
|
||||
|
||||
const priorityLabel: Record<TicketWithDetails["priority"], string> = {
|
||||
LOW: "Baixa",
|
||||
|
|
@ -49,12 +48,20 @@ function formatMinutes(value?: number | null) {
|
|||
return `${value} min`
|
||||
}
|
||||
|
||||
type SummaryChipConfig = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
tone: SummaryTone
|
||||
labelClassName?: string
|
||||
}
|
||||
|
||||
export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
||||
const isAvulso = Boolean(ticket.company?.isAvulso)
|
||||
const companyLabel = ticket.company?.name ?? (isAvulso ? "Cliente avulso" : "Sem empresa vinculada")
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; tone: SummaryTone }> = [
|
||||
const chips: SummaryChipConfig[] = [
|
||||
{
|
||||
key: "queue",
|
||||
label: "Fila",
|
||||
|
|
@ -87,11 +94,12 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
},
|
||||
]
|
||||
if (ticket.formTemplateLabel) {
|
||||
chips.push({
|
||||
chips.splice(Math.min(chips.length, 5), 0, {
|
||||
key: "formTemplate",
|
||||
label: "Tipo de solicitação",
|
||||
label: "Fluxo",
|
||||
value: ticket.formTemplateLabel,
|
||||
tone: "info",
|
||||
tone: "primary",
|
||||
labelClassName: "text-white",
|
||||
})
|
||||
}
|
||||
return chips
|
||||
|
|
@ -117,7 +125,7 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-semibold text-neutral-900">Detalhes</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-500">
|
||||
Resumo do ticket, métricas de SLA e tempo dedicado pela equipe.
|
||||
Resumo do ticket, métricas de SLA, tempo dedicado e campos personalizados.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{isAvulso ? (
|
||||
|
|
@ -131,8 +139,8 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Resumo</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{summaryChips.map(({ key, label, value, tone }) => (
|
||||
<SummaryChip key={key} label={label} value={value} tone={tone} />
|
||||
{summaryChips.map(({ key, label, value, tone, labelClassName }) => (
|
||||
<SummaryChip key={key} label={label} value={value} tone={tone} labelClassName={labelClassName} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -190,8 +198,6 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<TicketCustomFieldsSection ticket={ticket} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Tempo por agente</h3>
|
||||
{agentTotals.length > 0 ? (
|
||||
|
|
@ -255,7 +261,17 @@ export function TicketDetailsPanel({ ticket }: TicketDetailsPanelProps) {
|
|||
)
|
||||
}
|
||||
|
||||
function SummaryChip({ label, value, tone = "default" }: { label: string; value: string; tone?: SummaryTone }) {
|
||||
function SummaryChip({
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
labelClassName,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
tone?: SummaryTone
|
||||
labelClassName?: string
|
||||
}) {
|
||||
const toneClasses: Record<SummaryTone, string> = {
|
||||
default: "border-slate-200 bg-white text-neutral-900",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-900",
|
||||
|
|
@ -263,11 +279,12 @@ function SummaryChip({ label, value, tone = "default" }: { label: string; value:
|
|||
success: "border-emerald-200 bg-emerald-50 text-emerald-800",
|
||||
muted: "border-slate-200 bg-slate-50 text-neutral-600",
|
||||
danger: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
primary: "border-black bg-black text-white",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-xl border px-3 py-2 shadow-sm", toneClasses[tone])}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-neutral-500">{label}</p>
|
||||
<p className={cn("text-[11px] font-semibold uppercase tracking-wide text-neutral-500", labelClassName)}>{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-current">{value}</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import type { Id } from "@/convex/_generated/dataModel"
|
|||
|
||||
interface TicketQueueSummaryProps {
|
||||
queues?: TicketQueueSummary[]
|
||||
layout?: "default" | "compact"
|
||||
}
|
||||
|
||||
export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
||||
export function TicketQueueSummaryCards({ queues, layout = "default" }: TicketQueueSummaryProps) {
|
||||
const { convexUserId, isStaff } = useAuth()
|
||||
const enabled = Boolean(isStaff && convexUserId)
|
||||
const shouldFetch = Boolean(!queues && enabled)
|
||||
|
|
@ -23,75 +24,87 @@ export function TicketQueueSummaryCards({ queues }: TicketQueueSummaryProps) {
|
|||
) as TicketQueueSummary[] | undefined
|
||||
const data: TicketQueueSummary[] = queues ?? fromServer ?? []
|
||||
|
||||
const gridLayoutClass =
|
||||
layout === "compact"
|
||||
? "mx-auto grid w-full max-w-5xl grid-cols-1 gap-5 pb-3 md:grid-cols-2"
|
||||
: "grid w-full grid-cols-1 gap-5 pb-3 sm:grid-cols-2 md:grid-cols-3"
|
||||
|
||||
if (!queues && shouldFetch && fromServer === undefined) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
// Usa o mesmo grid do estado carregado para não “pular”
|
||||
<div className="h-full min-h-0 overflow-auto">
|
||||
<div className={gridLayoutClass}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-100" />
|
||||
<div className="mt-4 h-3 w-full animate-pulse rounded bg-slate-100" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(18rem,1fr))]">
|
||||
{data.map((queue) => {
|
||||
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||
return (
|
||||
<Card
|
||||
key={queue.id}
|
||||
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
|
||||
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
|
||||
{queue.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
|
||||
Pendentes
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
|
||||
{queue.pending}
|
||||
</p>
|
||||
<div className="h-full min-h-0 overflow-auto">
|
||||
{/* Grade responsiva: compacta no modo widget, ampla nos demais contextos */}
|
||||
<div className={gridLayoutClass}>
|
||||
{data.map((queue) => {
|
||||
const totalOpen = queue.pending + queue.inProgress + queue.paused
|
||||
const breachPercent = totalOpen === 0 ? 0 : Math.round((queue.breached / totalOpen) * 100)
|
||||
return (
|
||||
<Card
|
||||
key={queue.id}
|
||||
className="min-w-0 rounded-2xl border border-slate-200 bg-white p-3.5 shadow-sm sm:p-4"
|
||||
>
|
||||
<CardHeader className="min-w-0 pb-1.5 sm:pb-2">
|
||||
<CardDescription className="text-xs uppercase tracking-wide text-neutral-500">Fila</CardDescription>
|
||||
<CardTitle className="min-w-0 line-clamp-2 text-lg font-semibold leading-tight text-neutral-900 sm:text-xl">
|
||||
{queue.name}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{/* min-w-0 evita conteúdo interno empurrar a coluna */}
|
||||
<CardContent className="min-w-0 space-y-3 sm:space-y-4">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-2.5 sm:grid-cols-3">
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-slate-200 bg-gradient-to-br from-white via-white to-slate-100 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-neutral-500 sm:text-[0.72rem] lg:text-xs">
|
||||
Pendentes
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-neutral-900 tabular-nums sm:text-3xl">
|
||||
{queue.pending}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Em andamento
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
||||
{queue.inProgress}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Pausados
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
||||
{queue.paused}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-sky-200 bg-gradient-to-br from-white via-white to-sky-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-sky-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Em andamento
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-sky-700 tabular-nums sm:text-3xl">
|
||||
{queue.inProgress}
|
||||
</p>
|
||||
<div className="pt-1">
|
||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||
<span className="mt-2 block text-xs text-neutral-500">
|
||||
{breachPercent}% dos chamados da fila estão fora do SLA
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-neutral-400">
|
||||
Em atraso: {queue.breached}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col items-start justify-between gap-2 rounded-xl border border-amber-200 bg-gradient-to-br from-white via-white to-amber-50 px-3 py-2.5 text-left shadow-sm lg:px-4 lg:py-3">
|
||||
<p className="min-w-0 pr-0.5 text-[0.68rem] font-semibold uppercase leading-tight tracking-[0.02em] text-amber-700 sm:text-[0.72rem] lg:text-xs">
|
||||
Pausados
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight text-amber-700 tabular-nums sm:text-3xl">
|
||||
{queue.paused}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Progress value={breachPercent} className="h-1.5 bg-slate-100" indicatorClassName="bg-[#00e8ff]" />
|
||||
<span className="mt-2 block text-xs text-neutral-500">
|
||||
{breachPercent}% dos chamados da fila estão fora do SLA
|
||||
</span>
|
||||
<span className="mt-1 block text-xs text-neutral-400">
|
||||
Em atraso: {queue.breached}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { PrioritySelect } from "@/components/tickets/priority-select"
|
|||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||
import { StatusSelect } from "@/components/tickets/status-select"
|
||||
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
|
||||
import { CheckCircle2 } from "lucide-react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
|
@ -1568,6 +1569,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={sectionValueClass}>{ticket.slaPolicy.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<TicketCustomFieldsSection ticket={ticket} variant="inline" className="sm:col-span-2 lg:col-span-3" />
|
||||
{editing ? (
|
||||
<div className="flex items-center justify-end gap-2 sm:col-span-2 lg:col-span-3">
|
||||
<Button variant="ghost" size="sm" className="text-sm font-semibold text-neutral-700" onClick={handleCancel}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue