feat(checklist): adiciona tipo pergunta e descricao nos itens

- Adiciona campo `type` (checkbox/question) nos itens do checklist
- Adiciona campo `description` para descricao do item
- Adiciona campo `options` para opcoes de resposta em perguntas
- Adiciona campo `answer` para resposta selecionada
- Atualiza UI para mostrar descricao e opcoes de pergunta
- Cria componente radio-group para selecao de respostas
- Adiciona mutation setChecklistItemAnswer para salvar respostas

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rever-tecnologia 2025-12-15 16:27:23 -03:00
parent 98b23af4b2
commit 0f3ba07a5e
10 changed files with 446 additions and 76 deletions

View file

@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { Plus, Trash2 } from "lucide-react"
import { HelpCircle, Plus, Trash2, X } from "lucide-react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -20,25 +20,46 @@ import { SearchableCombobox, type SearchableComboboxOption } from "@/components/
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
type ChecklistItemType = "checkbox" | "question"
type ChecklistTemplateRow = {
id: Id<"ticketChecklistTemplates">
name: string
description: string
company: { id: Id<"companies">; name: string } | null
items: Array<{ id: string; text: string; required: boolean }>
items: Array<{
id: string
text: string
description?: string
type?: ChecklistItemType
options?: string[]
required: boolean
}>
isArchived: boolean
updatedAt: number
}
type CompanyRow = { id: Id<"companies">; name: string }
type DraftItem = { id: string; text: string; required: boolean }
type DraftItem = {
id: string
text: string
description: string
type: ChecklistItemType
options: string[]
required: boolean
}
const NO_COMPANY_VALUE = "__global__"
function normalizeTemplateItems(items: DraftItem[]) {
const normalized = items
.map((item) => ({ ...item, text: item.text.trim() }))
.map((item) => ({
...item,
text: item.text.trim(),
description: item.description.trim(),
options: item.type === "question" ? item.options.map((o) => o.trim()).filter((o) => o.length > 0) : [],
}))
.filter((item) => item.text.length > 0)
if (normalized.length === 0) {
@ -46,11 +67,26 @@ function normalizeTemplateItems(items: DraftItem[]) {
}
const invalid = normalized.find((item) => item.text.length > 240)
if (invalid) {
throw new Error("Item do checklist muito longo (máx. 240 caracteres).")
throw new Error("Item do checklist muito longo (max. 240 caracteres).")
}
const invalidQuestion = normalized.find((item) => item.type === "question" && item.options.length < 2)
if (invalidQuestion) {
throw new Error(`A pergunta "${invalidQuestion.text}" precisa ter pelo menos 2 opcoes.`)
}
return normalized
}
function createEmptyItem(): DraftItem {
return {
id: crypto.randomUUID(),
text: "",
description: "",
type: "checkbox",
options: [],
required: true,
}
}
function TemplateEditorDialog({
open,
onOpenChange,
@ -73,7 +109,7 @@ function TemplateEditorDialog({
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [companyValue, setCompanyValue] = useState<string>(NO_COMPANY_VALUE)
const [items, setItems] = useState<DraftItem[]>([{ id: crypto.randomUUID(), text: "", required: true }])
const [items, setItems] = useState<DraftItem[]>([createEmptyItem()])
const [archived, setArchived] = useState<boolean>(false)
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
@ -92,8 +128,15 @@ function TemplateEditorDialog({
setCompanyValue(template?.company?.id ? String(template.company.id) : NO_COMPANY_VALUE)
setItems(
template?.items?.length
? template.items.map((item) => ({ id: item.id, text: item.text, required: item.required }))
: [{ id: crypto.randomUUID(), text: "", required: true }]
? template.items.map((item) => ({
id: item.id,
text: item.text,
description: item.description ?? "",
type: (item.type ?? "checkbox") as ChecklistItemType,
options: item.options ?? [],
required: item.required,
}))
: [createEmptyItem()]
)
setArchived(template?.isArchived ?? false)
}, [open, template])
@ -119,7 +162,14 @@ function TemplateEditorDialog({
name: trimmedName,
description: description.trim().length > 0 ? description.trim() : undefined,
companyId: companyValue !== NO_COMPANY_VALUE ? (companyValue as Id<"companies">) : undefined,
items: normalizedItems.map((item) => ({ id: item.id, text: item.text, required: item.required })),
items: normalizedItems.map((item) => ({
id: item.id,
text: item.text,
description: item.description.length > 0 ? item.description : undefined,
type: item.type,
options: item.type === "question" && item.options.length > 0 ? item.options : undefined,
required: item.required,
})),
isArchived: archived,
}
@ -178,49 +228,137 @@ function TemplateEditorDialog({
<div className="flex items-center justify-between gap-2">
<div className="space-y-0.5">
<p className="text-sm font-semibold text-neutral-900">Itens</p>
<p className="text-xs text-muted-foreground">Defina o que precisa ser feito. Itens obrigatórios bloqueiam o encerramento.</p>
<p className="text-xs text-muted-foreground">Defina o que precisa ser feito. Itens obrigatorios bloqueiam o encerramento.</p>
</div>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setItems((prev) => [...prev, { id: crypto.randomUUID(), text: "", required: true }])}
onClick={() => setItems((prev) => [...prev, createEmptyItem()])}
>
<Plus className="size-4" />
Novo item
</Button>
</div>
<div className="space-y-2">
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-white p-3 sm:flex-row sm:items-center">
<Input
value={item.text}
onChange={(e) =>
setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, text: e.target.value } : row)))
}
placeholder="Ex.: Validar backup"
className="h-9 flex-1"
/>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={item.required}
onCheckedChange={(checked) =>
setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, required: Boolean(checked) } : row)))
<div key={item.id} className="space-y-2 rounded-xl border border-slate-200 bg-white p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={item.text}
onChange={(e) =>
setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, text: e.target.value } : row)))
}
placeholder={item.type === "question" ? "Ex.: Emprestou algum equipamento?" : "Ex.: Validar backup"}
className="h-9 flex-1"
/>
Obrigatório
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
onClick={() => setItems((prev) => prev.filter((row) => row.id !== item.id))}
title="Remover"
>
<Trash2 className="size-4" />
</Button>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={item.required}
onCheckedChange={(checked) =>
setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, required: Boolean(checked) } : row)))
}
/>
Obrigatorio
</label>
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={item.type === "question"}
onCheckedChange={(checked) =>
setItems((prev) =>
prev.map((row) =>
row.id === item.id
? {
...row,
type: checked ? "question" : "checkbox",
options: checked ? (row.options.length > 0 ? row.options : ["Sim", "Nao"]) : [],
}
: row
)
)
}
/>
<HelpCircle className="size-4" />
Pergunta
</label>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:bg-red-50 hover:text-red-700"
onClick={() => setItems((prev) => prev.filter((row) => row.id !== item.id))}
title="Remover"
>
<Trash2 className="size-4" />
</Button>
</div>
<Input
value={item.description}
onChange={(e) =>
setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, description: e.target.value } : row)))
}
placeholder="Descricao (opcional)"
className="h-8 text-xs"
/>
{item.type === "question" && (
<div className="space-y-2 rounded-lg border border-dashed border-cyan-200 bg-cyan-50/50 p-2">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium text-cyan-700">Opcoes de resposta</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1 text-xs text-cyan-700 hover:bg-cyan-100 hover:text-cyan-800"
onClick={() =>
setItems((prev) =>
prev.map((row) => (row.id === item.id ? { ...row, options: [...row.options, ""] } : row))
)
}
>
<Plus className="size-3" />
Adicionar
</Button>
</div>
<div className="flex flex-wrap gap-2">
{item.options.map((option, optIdx) => (
<div key={optIdx} className="flex items-center gap-1">
<Input
value={option}
onChange={(e) =>
setItems((prev) =>
prev.map((row) =>
row.id === item.id
? { ...row, options: row.options.map((o, i) => (i === optIdx ? e.target.value : o)) }
: row
)
)
}
placeholder={`Opcao ${optIdx + 1}`}
className="h-7 w-24 text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:bg-red-50 hover:text-red-600"
onClick={() =>
setItems((prev) =>
prev.map((row) =>
row.id === item.id ? { ...row, options: row.options.filter((_, i) => i !== optIdx) } : row
)
)
}
>
<X className="size-3" />
</Button>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
@ -297,7 +435,14 @@ export function ChecklistTemplatesManager() {
name: tpl.name,
description: tpl.description || undefined,
companyId: tpl.company?.id ?? undefined,
items: tpl.items.map((item) => ({ id: item.id, text: item.text, required: item.required })),
items: tpl.items.map((item) => ({
id: item.id,
text: item.text,
description: item.description,
type: item.type ?? "checkbox",
options: item.options,
required: item.required,
})),
isArchived: !tpl.isArchived,
})
toast.success(tpl.isArchived ? "Template restaurado." : "Template arquivado.")

View file

@ -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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@ -60,6 +61,7 @@ export function TicketChecklistCard({
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)
@ -300,28 +302,36 @@ export function TicketChecklistCard({
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-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="flex min-w-0 flex-1 items-start gap-3">
{isQuestion ? (
<div className="mt-1 flex size-5 shrink-0 items-center justify-center rounded-full border border-slate-300 bg-slate-50 text-xs font-medium text-slate-600">
?
</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-1"
/>
)}
<div className="min-w-0 flex-1">
{editingId === item.id && canEdit && !isResolved ? (
<div className="flex items-center gap-2">
@ -357,18 +367,57 @@ export function TicketChecklistCard({
</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>
<>
<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>
{item.description && (
<p className="mt-0.5 text-xs text-slate-500">
{item.description}
</p>
)}
</>
)}
{isQuestion && options.length > 0 && (
<RadioGroup
value={item.answer ?? ""}
onValueChange={async (value) => {
if (!actorId || !canToggle) return
try {
await setChecklistItemAnswer({
ticketId: ticket.id as Id<"tickets">,
actorId,
itemId: item.id,
answer: value,
})
} catch (error) {
toast.error(error instanceof Error ? error.message : "Falha ao responder pergunta.")
}
}}
disabled={!canToggle || !actorId}
className="mt-2 flex flex-wrap gap-3"
>
{options.map((option) => (
<label
key={option}
className="flex cursor-pointer items-center gap-1.5 text-sm text-slate-700"
>
<RadioGroupItem value={option} />
{option}
</label>
))}
</RadioGroup>
)}
<div className="mt-1 flex flex-wrap items-center gap-2">
{required ? (
<Badge variant="secondary" className="rounded-full text-[11px]">
@ -379,6 +428,11 @@ export function TicketChecklistCard({
Opcional
</Badge>
)}
{isQuestion && (
<Badge variant="outline" className="rounded-full border-cyan-200 bg-cyan-50 text-[11px] text-cyan-700">
Pergunta
</Badge>
)}
{templateLabel ? (
<Badge variant="outline" className="rounded-full text-[11px]">
Template: {templateLabel}
@ -386,7 +440,7 @@ export function TicketChecklistCard({
) : null}
</div>
</div>
</label>
</div>
{canEdit && !isResolved ? (
<div className="flex shrink-0 items-center gap-1">

View file

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary size-2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }