Adiciona: - Query debugTemplateAndTicketChecklist para verificar dados no backend - Console.log no frontend para verificar dados recebidos Esses logs serao removidos apos identificar o problema. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
581 lines
25 KiB
TypeScript
581 lines
25 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { CheckCheck, ListChecks, Plus, RotateCcw, Trash2 } from "lucide-react"
|
|
import { toast } from "sonner"
|
|
|
|
import { api } from "@/convex/_generated/api"
|
|
import type { Id } from "@/convex/_generated/dataModel"
|
|
import type { TicketChecklistItem, TicketWithDetails } from "@/lib/schemas/ticket"
|
|
import { useAuth } from "@/lib/auth-client"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Progress } from "@/components/ui/progress"
|
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
|
|
type ChecklistTemplateRow = {
|
|
id: Id<"ticketChecklistTemplates">
|
|
name: string
|
|
company: { id: Id<"companies">; name: string } | null
|
|
}
|
|
|
|
function countRequiredPending(items: TicketChecklistItem[]) {
|
|
return items.filter((item) => (item.required ?? true) && !item.done).length
|
|
}
|
|
|
|
function countRequiredDone(items: TicketChecklistItem[]) {
|
|
return items.filter((item) => (item.required ?? true) && item.done).length
|
|
}
|
|
|
|
function countAllDone(items: TicketChecklistItem[]) {
|
|
return items.filter((item) => item.done).length
|
|
}
|
|
|
|
export function TicketChecklistCard({
|
|
ticket,
|
|
}: {
|
|
ticket: Pick<TicketWithDetails, "id" | "tenantId" | "company" | "checklist" | "status">
|
|
}) {
|
|
const { convexUserId, isAdmin, isStaff, role } = useAuth()
|
|
const actorId = (convexUserId ?? null) as Id<"users"> | null
|
|
const canEdit = Boolean(isAdmin || role === "agent")
|
|
const canToggleDone = Boolean(isStaff)
|
|
const isResolved = ticket.status === "RESOLVED"
|
|
|
|
const checklist = useMemo(() => ticket.checklist ?? [], [ticket.checklist])
|
|
|
|
// DEBUG: Verificar dados do checklist
|
|
useEffect(() => {
|
|
if (checklist.length > 0) {
|
|
console.log("[DEBUG] Checklist items:", checklist.map(item => ({
|
|
id: item.id,
|
|
text: item.text.substring(0, 30),
|
|
templateDescription: item.templateDescription,
|
|
description: item.description,
|
|
})))
|
|
}
|
|
}, [checklist])
|
|
const requiredTotal = useMemo(() => checklist.filter((item) => (item.required ?? true)).length, [checklist])
|
|
const requiredDone = useMemo(() => countRequiredDone(checklist), [checklist])
|
|
const requiredPending = useMemo(() => countRequiredPending(checklist), [checklist])
|
|
const allDone = useMemo(() => countAllDone(checklist), [checklist])
|
|
|
|
const totalItems = checklist.length
|
|
const progress = totalItems > 0 ? Math.round((allDone / totalItems) * 100) : 0
|
|
|
|
const addChecklistItem = useMutation(api.tickets.addChecklistItem)
|
|
const updateChecklistItemText = useMutation(api.tickets.updateChecklistItemText)
|
|
const setChecklistItemDone = useMutation(api.tickets.setChecklistItemDone)
|
|
const setChecklistItemRequired = useMutation(api.tickets.setChecklistItemRequired)
|
|
const setChecklistItemAnswer = useMutation(api.tickets.setChecklistItemAnswer)
|
|
const removeChecklistItem = useMutation(api.tickets.removeChecklistItem)
|
|
const completeAllChecklistItems = useMutation(api.tickets.completeAllChecklistItems)
|
|
const uncompleteAllChecklistItems = useMutation(api.tickets.uncompleteAllChecklistItems)
|
|
const applyChecklistTemplate = useMutation(api.tickets.applyChecklistTemplate)
|
|
|
|
const templates = useQuery(
|
|
api.checklistTemplates.listActive,
|
|
actorId
|
|
? {
|
|
tenantId: ticket.tenantId,
|
|
viewerId: actorId,
|
|
companyId: ticket.company?.id ? (ticket.company.id as Id<"companies">) : undefined,
|
|
}
|
|
: "skip"
|
|
) as ChecklistTemplateRow[] | undefined
|
|
|
|
const templateNameById = useMemo(() => {
|
|
const map = new Map<string, string>()
|
|
for (const tpl of templates ?? []) {
|
|
map.set(String(tpl.id), tpl.name)
|
|
}
|
|
return map
|
|
}, [templates])
|
|
|
|
const [newText, setNewText] = useState("")
|
|
const [newRequired, setNewRequired] = useState(true)
|
|
const [adding, setAdding] = useState(false)
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
const [editingText, setEditingText] = useState("")
|
|
const [savingText, setSavingText] = useState(false)
|
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
|
|
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
|
const [completingAll, setCompletingAll] = useState(false)
|
|
const [onlyPending, setOnlyPending] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<TicketChecklistItem | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
const handleAdd = async () => {
|
|
if (!actorId || !canEdit || isResolved) return
|
|
const text = newText.trim()
|
|
if (!text) {
|
|
toast.error("Informe o texto do item do checklist.")
|
|
return
|
|
}
|
|
setAdding(true)
|
|
try {
|
|
await addChecklistItem({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
text,
|
|
required: newRequired,
|
|
})
|
|
setNewText("")
|
|
setNewRequired(true)
|
|
toast.success("Item adicionado ao checklist.")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao adicionar item.")
|
|
} finally {
|
|
setAdding(false)
|
|
}
|
|
}
|
|
|
|
const handleSaveText = async () => {
|
|
if (!actorId || !canEdit || isResolved || !editingId) return
|
|
const text = editingText.trim()
|
|
if (!text) {
|
|
toast.error("Informe o texto do item do checklist.")
|
|
return
|
|
}
|
|
setSavingText(true)
|
|
try {
|
|
await updateChecklistItemText({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
itemId: editingId,
|
|
text,
|
|
})
|
|
setEditingId(null)
|
|
setEditingText("")
|
|
toast.success("Item atualizado.")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao atualizar item.")
|
|
} finally {
|
|
setSavingText(false)
|
|
}
|
|
}
|
|
|
|
const handleApplyTemplate = async () => {
|
|
if (!actorId || !canEdit || isResolved) return
|
|
const templateId = selectedTemplateId.trim()
|
|
if (!templateId) return
|
|
setApplyingTemplate(true)
|
|
try {
|
|
const result = await applyChecklistTemplate({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
templateId: templateId as Id<"ticketChecklistTemplates">,
|
|
})
|
|
const added = typeof result?.added === "number" ? result.added : 0
|
|
toast.success(added > 0 ? `Checklist aplicado (${added} novo${added === 1 ? "" : "s"}).` : "Checklist já estava aplicado.")
|
|
setSelectedTemplateId("")
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao aplicar template.")
|
|
} finally {
|
|
setApplyingTemplate(false)
|
|
}
|
|
}
|
|
|
|
const allItemsDone = checklist.length > 0 && allDone === totalItems
|
|
|
|
const handleToggleAll = async () => {
|
|
if (!actorId || !canEdit || isResolved) return
|
|
setCompletingAll(true)
|
|
try {
|
|
if (allItemsDone) {
|
|
await uncompleteAllChecklistItems({ ticketId: ticket.id as Id<"tickets">, actorId })
|
|
toast.success("Itens desmarcados.")
|
|
} else {
|
|
await completeAllChecklistItems({ ticketId: ticket.id as Id<"tickets">, actorId })
|
|
toast.success("Checklist concluído.")
|
|
}
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao atualizar checklist.")
|
|
} finally {
|
|
setCompletingAll(false)
|
|
}
|
|
}
|
|
|
|
const canToggleAll = checklist.length > 0 && canEdit && !isResolved
|
|
const visibleChecklist = useMemo(() => {
|
|
return onlyPending ? checklist.filter((item) => !item.done) : checklist
|
|
}, [checklist, onlyPending])
|
|
const isLargeChecklist = totalItems > 12
|
|
|
|
return (
|
|
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
|
<CardHeader className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
|
<div className="space-y-1">
|
|
<CardTitle className="flex items-center gap-2 text-base font-semibold text-neutral-900">
|
|
<ListChecks className="size-5 text-neutral-700" />
|
|
Checklist
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-neutral-600">
|
|
{totalItems > 0 ? (
|
|
<>
|
|
{allDone}/{totalItems} itens concluídos
|
|
{requiredTotal > 0 ? ` • ${requiredDone}/${requiredTotal} obrigatórios` : ""}
|
|
</>
|
|
) : (
|
|
"Nenhum item cadastrado."
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{allDone > 0 && !isResolved ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setEditingId(null)
|
|
setEditingText("")
|
|
setOnlyPending((prev) => !prev)
|
|
}}
|
|
>
|
|
{onlyPending ? "Ver todos" : "Somente pendentes"}
|
|
</Button>
|
|
) : null}
|
|
{canEdit && !isResolved && canToggleAll ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2"
|
|
onClick={handleToggleAll}
|
|
disabled={completingAll}
|
|
title={allItemsDone ? "Desmarcar todos os itens" : "Marcar todos os itens como concluídos"}
|
|
>
|
|
{allItemsDone ? (
|
|
<>
|
|
<RotateCcw className="size-4" />
|
|
Desmarcar todos
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCheck className="size-4" />
|
|
Concluir todos
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
{canEdit && !isResolved && (templates ?? []).length > 0 ? (
|
|
<div className="flex items-center gap-2">
|
|
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
|
<SelectTrigger size="sm" className="w-[220px]">
|
|
<SelectValue placeholder="Aplicar template..." />
|
|
</SelectTrigger>
|
|
<SelectContent className="rounded-xl">
|
|
{(templates ?? []).map((tpl) => (
|
|
<SelectItem key={tpl.id} value={String(tpl.id)}>
|
|
{tpl.name}
|
|
{tpl.company ? ` — ${tpl.company.name}` : ""}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
type="button"
|
|
onClick={handleApplyTemplate}
|
|
disabled={!selectedTemplateId || applyingTemplate}
|
|
size="sm"
|
|
>
|
|
Aplicar
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<Progress value={progress} className="h-2 flex-1" indicatorClassName="bg-neutral-900" />
|
|
<span className="text-xs font-semibold tabular-nums text-neutral-800">{progress}%</span>
|
|
</div>
|
|
|
|
{checklist.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-slate-200 p-4 text-sm text-neutral-600">
|
|
{canEdit && !isResolved ? "Adicione itens para controlar o atendimento antes de encerrar." : "Nenhum checklist informado."}
|
|
</div>
|
|
) : (
|
|
<ScrollArea className={isLargeChecklist ? "max-h-[60vh] pr-1" : undefined}>
|
|
<div className="space-y-2">
|
|
{visibleChecklist.length === 0 ? (
|
|
<div className="rounded-xl border border-dashed border-slate-200 bg-white p-4 text-sm text-neutral-600">
|
|
Nenhum item pendente.
|
|
</div>
|
|
) : (
|
|
visibleChecklist.map((item) => {
|
|
const required = item.required ?? true
|
|
const canToggle = canToggleDone && !isResolved
|
|
const templateLabel = item.templateId ? templateNameById.get(String(item.templateId)) ?? null : null
|
|
const isQuestion = item.type === "question"
|
|
const options = item.options ?? []
|
|
|
|
return (
|
|
<div key={item.id} className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3">
|
|
<div className="flex min-w-0 flex-1 items-start gap-3">
|
|
{isQuestion ? (
|
|
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-neutral-900/80 text-xs font-semibold text-white">
|
|
?
|
|
</div>
|
|
) : (
|
|
<Checkbox
|
|
checked={item.done}
|
|
disabled={!canToggle || !actorId}
|
|
onCheckedChange={async (checked) => {
|
|
if (!actorId || !canToggle) return
|
|
try {
|
|
await setChecklistItemDone({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
itemId: item.id,
|
|
done: Boolean(checked),
|
|
})
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao atualizar checklist.")
|
|
}
|
|
}}
|
|
className="mt-0.5"
|
|
/>
|
|
)}
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
{editingId === item.id && canEdit && !isResolved ? (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
value={editingText}
|
|
onChange={(e) => setEditingText(e.target.value)}
|
|
className="h-9"
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
handleSaveText()
|
|
}
|
|
if (e.key === "Escape") {
|
|
setEditingId(null)
|
|
setEditingText("")
|
|
}
|
|
}}
|
|
/>
|
|
<Button type="button" onClick={handleSaveText} disabled={savingText} className="h-9">
|
|
Salvar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditingId(null)
|
|
setEditingText("")
|
|
}}
|
|
className="h-9"
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<p
|
|
className={`text-sm ${item.done ? "text-neutral-500 line-through" : "text-neutral-900"}`}
|
|
title={item.text}
|
|
onDoubleClick={() => {
|
|
if (!canEdit || isResolved) return
|
|
setEditingId(item.id)
|
|
setEditingText(item.text)
|
|
}}
|
|
>
|
|
{item.text}
|
|
</p>
|
|
)}
|
|
|
|
{isQuestion && options.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{options.map((option) => {
|
|
const isSelected = item.answer === option
|
|
return (
|
|
<button
|
|
key={option}
|
|
type="button"
|
|
disabled={!canToggle || !actorId}
|
|
onClick={async () => {
|
|
if (!actorId || !canToggle) return
|
|
try {
|
|
await setChecklistItemAnswer({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
itemId: item.id,
|
|
// Toggle: se já está selecionado, limpa; senão, seleciona
|
|
answer: isSelected ? undefined : option,
|
|
})
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
|
|
}
|
|
}}
|
|
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
|
|
isSelected
|
|
? "border-neutral-900 bg-neutral-900 text-white"
|
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50"
|
|
} ${!canToggle || !actorId ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}
|
|
>
|
|
{option}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{required ? (
|
|
<Badge variant="secondary" className="rounded-full px-2.5 py-0.5 text-[11px] font-medium">
|
|
Obrigatório
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-medium">
|
|
Opcional
|
|
</Badge>
|
|
)}
|
|
{isQuestion && (
|
|
<Badge variant="outline" className="rounded-full border-slate-300 bg-slate-100 px-2.5 py-0.5 text-[11px] font-medium text-slate-600">
|
|
Pergunta
|
|
</Badge>
|
|
)}
|
|
{templateLabel ? (
|
|
<Badge variant="outline" className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-slate-500">
|
|
{templateLabel}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
{(item.templateDescription || item.description) && (
|
|
<div className="space-y-0.5">
|
|
{item.templateDescription && (
|
|
<p className="text-xs italic text-slate-400">
|
|
{item.templateDescription}
|
|
</p>
|
|
)}
|
|
{item.description && (
|
|
<p className="text-xs text-slate-500">
|
|
{item.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{canEdit && !isResolved ? (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={async () => {
|
|
if (!actorId) return
|
|
try {
|
|
await setChecklistItemRequired({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
itemId: item.id,
|
|
required: !required,
|
|
})
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao atualizar item.")
|
|
}
|
|
}}
|
|
>
|
|
{required ? "Tornar opcional" : "Tornar obrigatório"}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
|
|
onClick={() => setDeleteTarget(item)}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
{canEdit && !isResolved ? (
|
|
<div className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Input
|
|
value={newText}
|
|
onChange={(e) => setNewText(e.target.value)}
|
|
placeholder="Adicionar item do checklist..."
|
|
className="h-8 w-full bg-white sm:max-w-md"
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
handleAdd()
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex h-8 items-center gap-2 text-sm text-neutral-700">
|
|
<Checkbox checked={newRequired} onCheckedChange={(checked) => setNewRequired(Boolean(checked))} />
|
|
Obrigatório
|
|
</label>
|
|
<Button type="button" size="sm" onClick={handleAdd} disabled={adding} className="gap-2">
|
|
<Plus className="size-4" />
|
|
Adicionar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
|
|
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Remover item do checklist</DialogTitle>
|
|
<DialogDescription>
|
|
Tem certeza que deseja remover o item <strong>"{deleteTarget?.text}"</strong> do checklist?
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter className="gap-3">
|
|
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
|
Cancelar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={deleting}
|
|
onClick={async () => {
|
|
if (!actorId || !deleteTarget) return
|
|
setDeleting(true)
|
|
try {
|
|
await removeChecklistItem({
|
|
ticketId: ticket.id as Id<"tickets">,
|
|
actorId,
|
|
itemId: deleteTarget.id,
|
|
})
|
|
toast.success("Item removido.")
|
|
setDeleteTarget(null)
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : "Falha ao remover item.")
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}}
|
|
>
|
|
{deleting ? "Removendo..." : "Remover"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</Card>
|
|
)
|
|
}
|