feat(ui): checklist com rolagem e progresso
This commit is contained in:
parent
7c82ef18b3
commit
e0fcd5bf7c
1 changed files with 176 additions and 141 deletions
|
|
@ -15,6 +15,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
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 = {
|
||||
|
|
@ -52,7 +53,8 @@ export function TicketChecklistCard({
|
|||
const requiredPending = useMemo(() => countRequiredPending(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 updateChecklistItemText = useMutation(api.tickets.updateChecklistItemText)
|
||||
|
|
@ -92,6 +94,7 @@ export function TicketChecklistCard({
|
|||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("")
|
||||
const [applyingTemplate, setApplyingTemplate] = useState(false)
|
||||
const [completingAll, setCompletingAll] = useState(false)
|
||||
const [onlyPending, setOnlyPending] = useState(false)
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!actorId || !canEdit || isResolved) return
|
||||
|
|
@ -178,6 +181,10 @@ export function TicketChecklistCard({
|
|||
}
|
||||
|
||||
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 (
|
||||
<Card className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
|
|
@ -188,14 +195,31 @@ export function TicketChecklistCard({
|
|||
Checklist
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-neutral-600">
|
||||
{requiredTotal > 0
|
||||
? `${requiredDone}/${requiredTotal} itens obrigatórios concluídos`
|
||||
: checklist.length > 0
|
||||
? `${allDone}/${checklist.length} itens concluídos`
|
||||
: "Nenhum item cadastrado."}
|
||||
{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 ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -238,158 +262,169 @@ export function TicketChecklistCard({
|
|||
</div>
|
||||
</CardHeader>
|
||||
<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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{checklist.map((item) => {
|
||||
const required = item.required ?? true
|
||||
const canToggle = canToggleDone && !isResolved
|
||||
const templateLabel = item.templateId ? templateNameById.get(String(item.templateId)) ?? null : null
|
||||
<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
|
||||
|
||||
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">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<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-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{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("")
|
||||
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">
|
||||
<label className="flex min-w-0 flex-1 items-start gap-3">
|
||||
<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-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
{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={`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">
|
||||
Salvar
|
||||
>
|
||||
{required ? "Tornar opcional" : "Tornar obrigatório"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingId(null)
|
||||
setEditingText("")
|
||||
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.")
|
||||
}
|
||||
}}
|
||||
className="h-9"
|
||||
>
|
||||
Cancelar
|
||||
<Trash2 className="size-4" />
|
||||
</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>
|
||||
) : null}
|
||||
</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.")
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{canEdit && !isResolved ? (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue