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:
parent
98b23af4b2
commit
0f3ba07a5e
10 changed files with 446 additions and 76 deletions
3
bun.lock
3
bun.lock
|
|
@ -21,6 +21,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
@ -512,6 +513,8 @@
|
|||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
|
|
|||
|
|
@ -14,17 +14,37 @@ function normalizeTemplateDescription(input: string | null | undefined) {
|
|||
return text.length > 0 ? text : null
|
||||
}
|
||||
|
||||
type ChecklistItemType = "checkbox" | "question"
|
||||
|
||||
type RawTemplateItem = {
|
||||
id?: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: string
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
type NormalizedTemplateItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: ChecklistItemType
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
function normalizeTemplateItems(
|
||||
raw: Array<{ id?: string; text: string; required?: boolean }>,
|
||||
raw: RawTemplateItem[],
|
||||
options: { generateId?: () => string }
|
||||
) {
|
||||
): NormalizedTemplateItem[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
throw new ConvexError("Adicione pelo menos um item no checklist.")
|
||||
}
|
||||
|
||||
const generateId = options.generateId ?? (() => crypto.randomUUID())
|
||||
const seen = new Set<string>()
|
||||
const items: Array<{ id: string; text: string; required?: boolean }> = []
|
||||
const items: NormalizedTemplateItem[] = []
|
||||
|
||||
for (const entry of raw) {
|
||||
const id = String(entry.id ?? "").trim() || generateId()
|
||||
|
|
@ -38,11 +58,28 @@ function normalizeTemplateItems(
|
|||
throw new ConvexError("Todos os itens do checklist precisam ter um texto.")
|
||||
}
|
||||
if (text.length > 240) {
|
||||
throw new ConvexError("Item do checklist muito longo (máx. 240 caracteres).")
|
||||
throw new ConvexError("Item do checklist muito longo (max. 240 caracteres).")
|
||||
}
|
||||
|
||||
const description = entry.description?.trim() || undefined
|
||||
const itemType: ChecklistItemType = entry.type === "question" ? "question" : "checkbox"
|
||||
const itemOptions = itemType === "question" && Array.isArray(entry.options)
|
||||
? entry.options.map((o) => String(o).trim()).filter((o) => o.length > 0)
|
||||
: undefined
|
||||
|
||||
if (itemType === "question" && (!itemOptions || itemOptions.length < 2)) {
|
||||
throw new ConvexError(`A pergunta "${text}" precisa ter pelo menos 2 opcoes.`)
|
||||
}
|
||||
|
||||
const required = typeof entry.required === "boolean" ? entry.required : true
|
||||
items.push({ id, text, required })
|
||||
items.push({
|
||||
id,
|
||||
text,
|
||||
description,
|
||||
type: itemType,
|
||||
options: itemOptions,
|
||||
required,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
|
|
@ -57,6 +94,9 @@ function mapTemplate(template: Doc<"ticketChecklistTemplates">, company: Doc<"co
|
|||
items: (template.items ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
description: item.description,
|
||||
type: item.type ?? "checkbox",
|
||||
options: item.options,
|
||||
required: typeof item.required === "boolean" ? item.required : true,
|
||||
})),
|
||||
isArchived: Boolean(template.isArchived),
|
||||
|
|
@ -164,6 +204,9 @@ export const create = mutation({
|
|||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()),
|
||||
options: v.optional(v.array(v.string())),
|
||||
required: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
|
|
@ -216,6 +259,9 @@ export const update = mutation({
|
|||
v.object({
|
||||
id: v.optional(v.string()),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()),
|
||||
options: v.optional(v.array(v.string())),
|
||||
required: v.optional(v.boolean()),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -314,6 +314,10 @@ export default defineSchema({
|
|||
v.object({
|
||||
id: v.string(),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()), // "checkbox" | "question"
|
||||
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
|
||||
answer: v.optional(v.string()), // Resposta selecionada para tipo "question"
|
||||
done: v.boolean(),
|
||||
required: v.optional(v.boolean()),
|
||||
templateId: v.optional(v.id("ticketChecklistTemplates")),
|
||||
|
|
@ -659,6 +663,9 @@ export default defineSchema({
|
|||
v.object({
|
||||
id: v.string(),
|
||||
text: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
type: v.optional(v.string()), // "checkbox" | "question"
|
||||
options: v.optional(v.array(v.string())), // Para tipo "question": ["Sim", "Nao", ...]
|
||||
required: v.optional(v.boolean()),
|
||||
})
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
import type { Id } from "./_generated/dataModel"
|
||||
|
||||
export type ChecklistItemType = "checkbox" | "question"
|
||||
|
||||
export type TicketChecklistItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: ChecklistItemType
|
||||
options?: string[] // Para tipo "question": ["Sim", "Nao", ...]
|
||||
answer?: string // Resposta selecionada para tipo "question"
|
||||
done: boolean
|
||||
required?: boolean
|
||||
templateId?: Id<"ticketChecklistTemplates">
|
||||
|
|
@ -13,9 +19,18 @@ export type TicketChecklistItem = {
|
|||
doneBy?: Id<"users">
|
||||
}
|
||||
|
||||
export type TicketChecklistTemplateItem = {
|
||||
id: string
|
||||
text: string
|
||||
description?: string
|
||||
type?: string // "checkbox" | "question" - string para compatibilidade com schema
|
||||
options?: string[]
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export type TicketChecklistTemplateLike = {
|
||||
_id: Id<"ticketChecklistTemplates">
|
||||
items: Array<{ id: string; text: string; required?: boolean }>
|
||||
items: TicketChecklistTemplateItem[]
|
||||
}
|
||||
|
||||
export function normalizeChecklistText(input: string) {
|
||||
|
|
@ -53,9 +68,13 @@ export function applyChecklistTemplateToItems(
|
|||
const key = `${String(template._id)}:${templateItemId}`
|
||||
if (existingKeys.has(key)) continue
|
||||
existingKeys.add(key)
|
||||
const itemType = tplItem.type ?? "checkbox"
|
||||
next.push({
|
||||
id: generateId(),
|
||||
text,
|
||||
description: tplItem.description,
|
||||
type: itemType as ChecklistItemType,
|
||||
options: itemType === "question" ? tplItem.options : undefined,
|
||||
done: false,
|
||||
required: typeof tplItem.required === "boolean" ? tplItem.required : true,
|
||||
templateId: template._id,
|
||||
|
|
|
|||
|
|
@ -2669,6 +2669,49 @@ export const setChecklistItemRequired = mutation({
|
|||
},
|
||||
});
|
||||
|
||||
export const setChecklistItemAnswer = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
actorId: v.id("users"),
|
||||
itemId: v.string(),
|
||||
answer: v.string(),
|
||||
},
|
||||
handler: async (ctx, { ticketId, actorId, itemId, answer }) => {
|
||||
const ticket = await ctx.db.get(ticketId);
|
||||
if (!ticket) {
|
||||
throw new ConvexError("Ticket não encontrado");
|
||||
}
|
||||
const ticketDoc = ticket as Doc<"tickets">;
|
||||
await requireTicketStaff(ctx, actorId, ticketDoc);
|
||||
|
||||
const checklist = normalizeTicketChecklist(ticketDoc.checklist);
|
||||
const index = checklist.findIndex((item) => item.id === itemId);
|
||||
if (index < 0) {
|
||||
throw new ConvexError("Item do checklist não encontrado.");
|
||||
}
|
||||
|
||||
const item = checklist[index]!;
|
||||
if (item.type !== "question") {
|
||||
throw new ConvexError("Este item não é uma pergunta.");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const normalizedAnswer = answer.trim();
|
||||
const isDone = normalizedAnswer.length > 0;
|
||||
|
||||
const nextChecklist = checklist.map((it) => {
|
||||
if (it.id !== itemId) return it;
|
||||
if (isDone) {
|
||||
return { ...it, answer: normalizedAnswer, done: true, doneAt: now, doneBy: actorId };
|
||||
}
|
||||
return { ...it, answer: undefined, done: false, doneAt: undefined, doneBy: undefined };
|
||||
});
|
||||
|
||||
await ctx.db.patch(ticketId, { checklist: nextChecklist, updatedAt: now });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const removeChecklistItem = mutation({
|
||||
args: {
|
||||
ticketId: v.id("tickets"),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal 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 }
|
||||
|
|
@ -134,9 +134,16 @@ export const ticketCustomFieldValueSchema = z.object({
|
|||
})
|
||||
export type TicketCustomFieldValue = z.infer<typeof ticketCustomFieldValueSchema>
|
||||
|
||||
export const checklistItemTypeSchema = z.enum(["checkbox", "question"])
|
||||
export type ChecklistItemType = z.infer<typeof checklistItemTypeSchema>
|
||||
|
||||
export const ticketChecklistItemSchema = z.object({
|
||||
id: z.string(),
|
||||
text: z.string(),
|
||||
description: z.string().optional(),
|
||||
type: checklistItemTypeSchema.optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
answer: z.string().optional(),
|
||||
done: z.boolean(),
|
||||
required: z.boolean().optional(),
|
||||
templateId: z.string().optional(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue