feat(ui): checklist com rolagem e progresso

This commit is contained in:
esdrasrenan 2025-12-13 22:11:13 -03:00
parent 7c82ef18b3
commit e0fcd5bf7c

View file

@ -15,6 +15,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress" import { Progress } from "@/components/ui/progress"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
type ChecklistTemplateRow = { type ChecklistTemplateRow = {
@ -52,7 +53,8 @@ export function TicketChecklistCard({
const requiredPending = useMemo(() => countRequiredPending(checklist), [checklist]) const requiredPending = useMemo(() => countRequiredPending(checklist), [checklist])
const allDone = useMemo(() => countAllDone(checklist), [checklist]) const allDone = useMemo(() => countAllDone(checklist), [checklist])
const progress = requiredTotal > 0 ? Math.round((requiredDone / requiredTotal) * 100) : 100 const totalItems = checklist.length
const progress = totalItems > 0 ? Math.round((allDone / totalItems) * 100) : 0
const addChecklistItem = useMutation(api.tickets.addChecklistItem) const addChecklistItem = useMutation(api.tickets.addChecklistItem)
const updateChecklistItemText = useMutation(api.tickets.updateChecklistItemText) const updateChecklistItemText = useMutation(api.tickets.updateChecklistItemText)
@ -92,6 +94,7 @@ export function TicketChecklistCard({
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("") const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
const [applyingTemplate, setApplyingTemplate] = useState(false) const [applyingTemplate, setApplyingTemplate] = useState(false)
const [completingAll, setCompletingAll] = useState(false) const [completingAll, setCompletingAll] = useState(false)
const [onlyPending, setOnlyPending] = useState(false)
const handleAdd = async () => { const handleAdd = async () => {
if (!actorId || !canEdit || isResolved) return if (!actorId || !canEdit || isResolved) return
@ -178,6 +181,10 @@ export function TicketChecklistCard({
} }
const canComplete = checklist.length > 0 && requiredPending > 0 && canEdit && !isResolved const canComplete = checklist.length > 0 && requiredPending > 0 && canEdit && !isResolved
const visibleChecklist = useMemo(() => {
return onlyPending ? checklist.filter((item) => !item.done) : checklist
}, [checklist, onlyPending])
const isLargeChecklist = totalItems > 12
return ( return (
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm"> <Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
@ -188,14 +195,31 @@ export function TicketChecklistCard({
Checklist Checklist
</CardTitle> </CardTitle>
<CardDescription className="text-sm text-neutral-600"> <CardDescription className="text-sm text-neutral-600">
{requiredTotal > 0 {totalItems > 0 ? (
? `${requiredDone}/${requiredTotal} itens obrigatórios concluídos` <>
: checklist.length > 0 {allDone}/{totalItems} itens concluídos
? `${allDone}/${checklist.length} itens concluídos` {requiredTotal > 0 ? `${requiredDone}/${requiredTotal} obrigatórios` : ""}
: "Nenhum item cadastrado."} </>
) : (
"Nenhum item cadastrado."
)}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <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 ? ( {canEdit && !isResolved ? (
<Button <Button
type="button" type="button"
@ -238,158 +262,169 @@ export function TicketChecklistCard({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Progress value={progress} className="h-2" indicatorClassName="bg-emerald-500" /> <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 ? ( {checklist.length === 0 ? (
<div className="rounded-xl border border-dashed border-slate-200 p-4 text-sm text-neutral-600"> <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."} {canEdit && !isResolved ? "Adicione itens para controlar o atendimento antes de encerrar." : "Nenhum checklist informado."}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <ScrollArea className={isLargeChecklist ? "max-h-[60vh] pr-1" : undefined}>
{checklist.map((item) => { <div className="space-y-2">
const required = item.required ?? true {visibleChecklist.length === 0 ? (
const canToggle = canToggleDone && !isResolved <div className="rounded-xl border border-dashed border-slate-200 bg-white p-4 text-sm text-neutral-600">
const templateLabel = item.templateId ? templateNameById.get(String(item.templateId)) ?? null : null 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
return ( return (
<div key={item.id} className="flex items-start justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2"> <div key={item.id} className="flex items-start justify-between gap-3 rounded-xl border border-slate-200 bg-white px-3 py-2">
<label className="flex min-w-0 flex-1 items-start gap-3"> <label className="flex min-w-0 flex-1 items-start gap-3">
<Checkbox <Checkbox
checked={item.done} checked={item.done}
disabled={!canToggle || !actorId} disabled={!canToggle || !actorId}
onCheckedChange={async (checked) => { onCheckedChange={async (checked) => {
if (!actorId || !canToggle) return if (!actorId || !canToggle) return
try { try {
await setChecklistItemDone({ await setChecklistItemDone({
ticketId: ticket.id as Id<"tickets">, ticketId: ticket.id as Id<"tickets">,
actorId, actorId,
itemId: item.id, itemId: item.id,
done: Boolean(checked), done: Boolean(checked),
}) })
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao atualizar checklist.") toast.error(error instanceof Error ? error.message : "Falha ao atualizar checklist.")
} }
}} }}
className="mt-1" className="mt-1"
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{editingId === item.id && canEdit && !isResolved ? ( {editingId === item.id && canEdit && !isResolved ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
value={editingText} value={editingText}
onChange={(e) => setEditingText(e.target.value)} onChange={(e) => setEditingText(e.target.value)}
className="h-9" className="h-9"
autoFocus autoFocus
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault() e.preventDefault()
handleSaveText() handleSaveText()
} }
if (e.key === "Escape") { if (e.key === "Escape") {
setEditingId(null) setEditingId(null)
setEditingText("") 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={`truncate 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>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
{required ? (
<Badge variant="secondary" className="rounded-full text-[11px]">
Obrigatório
</Badge>
) : (
<Badge variant="outline" className="rounded-full text-[11px]">
Opcional
</Badge>
)}
{templateLabel ? (
<Badge variant="outline" className="rounded-full text-[11px]">
Template: {templateLabel}
</Badge>
) : null}
</div>
</div>
</label>
{canEdit && !isResolved ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
className="h-9 px-2 text-xs text-neutral-700 hover:bg-slate-100 hover:text-neutral-900 active:bg-slate-200/70 focus-visible:bg-slate-100"
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.")
} }
}} }}
/> >
<Button type="button" onClick={handleSaveText} disabled={savingText} className="h-9"> {required ? "Tornar opcional" : "Tornar obrigatório"}
Salvar
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
onClick={() => { size="icon"
setEditingId(null) className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
setEditingText("") title="Remover"
onClick={async () => {
if (!actorId) return
const ok = confirm("Remover este item do checklist?")
if (!ok) return
try {
await removeChecklistItem({
ticketId: ticket.id as Id<"tickets">,
actorId,
itemId: item.id,
})
toast.success("Item removido.")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao remover item.")
}
}} }}
className="h-9"
> >
Cancelar <Trash2 className="size-4" />
</Button> </Button>
</div> </div>
) : ( ) : null}
<p
className={`truncate 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>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
{required ? (
<Badge variant="secondary" className="rounded-full text-[11px]">
Obrigatório
</Badge>
) : (
<Badge variant="outline" className="rounded-full text-[11px]">
Opcional
</Badge>
)}
{templateLabel ? (
<Badge variant="outline" className="rounded-full text-[11px]">
Template: {templateLabel}
</Badge>
) : null}
</div>
</div> </div>
</label> )
})
{canEdit && !isResolved ? ( )}
<div className="flex shrink-0 items-center gap-1"> </div>
<Button </ScrollArea>
type="button"
variant="ghost"
className="h-9 px-2 text-xs text-neutral-700 hover:bg-slate-100 hover:text-neutral-900 active:bg-slate-200/70 focus-visible:bg-slate-100"
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"
title="Remover"
onClick={async () => {
if (!actorId) return
const ok = confirm("Remover este item do checklist?")
if (!ok) return
try {
await removeChecklistItem({
ticketId: ticket.id as Id<"tickets">,
actorId,
itemId: item.id,
})
toast.success("Item removido.")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao remover item.")
}
}}
>
<Trash2 className="size-4" />
</Button>
</div>
) : null}
</div>
)
})}
</div>
)} )}
{canEdit && !isResolved ? ( {canEdit && !isResolved ? (