diff --git a/bun.lock b/bun.lock index 013a138..d1095c6 100644 --- a/bun.lock +++ b/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=="], diff --git a/convex/checklistTemplates.ts b/convex/checklistTemplates.ts index 34c75cc..af3cf81 100644 --- a/convex/checklistTemplates.ts +++ b/convex/checklistTemplates.ts @@ -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() - 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()), }), ), diff --git a/convex/schema.ts b/convex/schema.ts index a327224..7409d2a 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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()), }) ), diff --git a/convex/ticketChecklist.ts b/convex/ticketChecklist.ts index efef60b..dd3e14f 100644 --- a/convex/ticketChecklist.ts +++ b/convex/ticketChecklist.ts @@ -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, diff --git a/convex/tickets.ts b/convex/tickets.ts index 1b2b91a..881dce8 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -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"), diff --git a/package.json b/package.json index 186a9fa..e62d82b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/settings/checklist-templates-manager.tsx b/src/components/settings/checklist-templates-manager.tsx index 72f1a6a..99f6b40 100644 --- a/src/components/settings/checklist-templates-manager.tsx +++ b/src/components/settings/checklist-templates-manager.tsx @@ -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(NO_COMPANY_VALUE) - const [items, setItems] = useState([{ id: crypto.randomUUID(), text: "", required: true }]) + const [items, setItems] = useState([createEmptyItem()]) const [archived, setArchived] = useState(false) const companyComboboxOptions = useMemo(() => { @@ -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({

Itens

-

Defina o que precisa ser feito. Itens obrigatórios bloqueiam o encerramento.

+

Defina o que precisa ser feito. Itens obrigatorios bloqueiam o encerramento.

-
+
{items.map((item) => ( -
- - setItems((prev) => prev.map((row) => (row.id === item.id ? { ...row, text: e.target.value } : row))) - } - placeholder="Ex.: Validar backup" - className="h-9 flex-1" - /> -
@@ -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.") diff --git a/src/components/tickets/ticket-checklist-card.tsx b/src/components/tickets/ticket-checklist-card.tsx index bbb8dc2..d8f0768 100644 --- a/src/components/tickets/ticket-checklist-card.tsx +++ b/src/components/tickets/ticket-checklist-card.tsx @@ -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 (
-