From 6473e8d40fdc99353f8fe6718cf35d92e5a77ba0 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 18 Nov 2025 17:42:38 -0300 Subject: [PATCH] feat: enhance visit scheduling and closing flow --- convex/tickets.ts | 49 ++ .../reports/report-templates-manager.tsx | 423 ++++++++++-------- .../tickets/close-ticket-dialog.tsx | 43 +- src/components/tickets/new-ticket-dialog.tsx | 144 ++++-- .../tickets/ticket-summary-header.tsx | 161 ++++++- 5 files changed, 577 insertions(+), 243 deletions(-) diff --git a/convex/tickets.ts b/convex/tickets.ts index e4fdadd..ce8212a 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -3469,6 +3469,55 @@ export const purgeTicketsForUsers = mutation({ }) +export const updateVisitSchedule = mutation({ + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + visitDate: v.number(), + }, + handler: async (ctx, { ticketId, actorId, visitDate }) => { + const ticket = await ctx.db.get(ticketId) + if (!ticket) { + throw new ConvexError("Ticket não encontrado") + } + const ticketDoc = ticket as Doc<"tickets"> + const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + if (viewer.role === "MANAGER") { + throw new ConvexError("Gestores não podem alterar a data da visita") + } + if (!Number.isFinite(visitDate)) { + throw new ConvexError("Data da visita inválida") + } + if (!ticketDoc.queueId) { + throw new ConvexError("Este ticket não possui fila configurada") + } + const queue = (await ctx.db.get(ticketDoc.queueId)) as Doc<"queues"> | null + if (!queue) { + throw new ConvexError("Fila não encontrada para este ticket") + } + const queueLabel = (normalizeQueueName(queue) ?? queue.name ?? "").toLowerCase() + const isVisitQueue = VISIT_QUEUE_KEYWORDS.some((keyword) => queueLabel.includes(keyword)) + if (!isVisitQueue) { + throw new ConvexError("Somente tickets da fila de visitas possuem data de visita") + } + const now = Date.now() + await ctx.db.patch(ticketId, { + dueAt: visitDate, + updatedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "VISIT_SCHEDULE_CHANGED", + payload: { + visitDate, + actorId, + }, + createdAt: now, + }) + return { status: "updated" } + }, +}) + export const changeQueue = mutation({ args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") }, handler: async (ctx, { ticketId, queueId, actorId }) => { diff --git a/src/components/admin/reports/report-templates-manager.tsx b/src/components/admin/reports/report-templates-manager.tsx index 8625596..d9e7b21 100644 --- a/src/components/admin/reports/report-templates-manager.tsx +++ b/src/components/admin/reports/report-templates-manager.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { Skeleton } from "@/components/ui/skeleton" import { cn } from "@/lib/utils" @@ -45,12 +46,15 @@ type ReportTemplatesManagerProps = { tenantId?: string | null } +const DEFAULT_COLUMN_KEYS = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key) + export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplatesManagerProps) { const { convexUserId, session, isAdmin } = useAuth() const tenantId = tenantIdProp ?? session?.user.tenantId ?? DEFAULT_TENANT_ID const [filterCompanyId, setFilterCompanyId] = useState("all") - const [selectedTemplateId, setSelectedTemplateId] = useState("new") + const [editingTemplateId, setEditingTemplateId] = useState(null) + const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false) const canLoad = Boolean(convexUserId && isAdmin) @@ -135,25 +139,30 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat const [isSaving, setIsSaving] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - const selectedTemplate = useMemo( - () => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null, - [templateOptions, selectedTemplateId] + const editingTemplate = useMemo( + () => templateOptions.find((tpl) => tpl.id === editingTemplateId) ?? null, + [templateOptions, editingTemplateId] ) + const resetForm = useCallback(() => { + setName("") + setDescription("") + setTargetCompanyId("all") + setIsDefault(false) + setIsActive(true) + setSelectedColumns([...DEFAULT_COLUMN_KEYS]) + }, []) + useEffect(() => { - if (!templates) return - if (selectedTemplateId === "new") { - setName("") - setDescription("") - setTargetCompanyId("all") - setIsDefault(false) - setIsActive(true) - setSelectedColumns(DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key)) + if (!isTemplateDialogOpen) return + if (!editingTemplateId) { + resetForm() return } - const tpl = templateOptions.find((t) => t.id === selectedTemplateId) + const tpl = templateOptions.find((t) => t.id === editingTemplateId) if (!tpl) { - setSelectedTemplateId("new") + setEditingTemplateId(null) + resetForm() return } setName(tpl.name) @@ -162,8 +171,8 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat setIsDefault(Boolean(tpl.isDefault)) setIsActive(tpl.isActive !== false) const columnKeys = (tpl.columns ?? []).map((col) => col.key) - setSelectedColumns(columnKeys.length > 0 ? columnKeys : []) - }, [templates, templateOptions, selectedTemplateId]) + setSelectedColumns(columnKeys.length > 0 ? columnKeys : [...DEFAULT_COLUMN_KEYS]) + }, [editingTemplateId, isTemplateDialogOpen, resetForm, templateOptions]) const handleToggleColumn = (key: string, checked: boolean) => { setSelectedColumns((prev) => { @@ -208,7 +217,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat try { setIsSaving(true) - if (!selectedTemplate || selectedTemplateId === "new") { + if (!editingTemplate) { await createTemplate({ tenantId, actorId: convexUserId as Id<"users">, @@ -224,20 +233,20 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat await updateTemplate({ tenantId, actorId: convexUserId as Id<"users">, - templateId: selectedTemplate.id as Id<"deviceExportTemplates">, + templateId: editingTemplate.id as Id<"deviceExportTemplates">, name: trimmedName, description: description.trim() || undefined, columns: columnsPayload, - filters: selectedTemplate.filters ?? undefined, + filters: editingTemplate.filters ?? undefined, companyId: companyIdValue, isDefault, isActive, }) } toast.success("Template salvo com sucesso.") - if (selectedTemplateId === "new") { - setSelectedTemplateId("new") - } + setIsTemplateDialogOpen(false) + setEditingTemplateId(null) + resetForm() } catch (error) { console.error("[report-templates] Failed to save template", error) toast.error("Não foi possível salvar o template.") @@ -247,16 +256,18 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat } const handleDelete = async () => { - if (!convexUserId || !selectedTemplate) return + if (!convexUserId || !editingTemplate) return try { setIsDeleting(true) await removeTemplate({ tenantId, actorId: convexUserId as Id<"users">, - templateId: selectedTemplate.id as Id<"deviceExportTemplates">, + templateId: editingTemplate.id as Id<"deviceExportTemplates">, }) toast.success("Template removido com sucesso.") - setSelectedTemplateId("new") + setIsTemplateDialogOpen(false) + setEditingTemplateId(null) + resetForm() } catch (error) { console.error("[report-templates] Failed to delete template", error) toast.error("Não foi possível remover o template.") @@ -265,6 +276,30 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat } } + const handleTemplateDialogOpenChange = useCallback( + (open: boolean) => { + if (!open) { + if (isSaving || isDeleting) return + setIsTemplateDialogOpen(false) + setEditingTemplateId(null) + resetForm() + return + } + setIsTemplateDialogOpen(true) + }, + [isDeleting, isSaving, resetForm] + ) + + const handleOpenCreateDialog = useCallback(() => { + setEditingTemplateId(null) + setIsTemplateDialogOpen(true) + }, []) + + const handleTemplateRowClick = useCallback((templateId: string) => { + setEditingTemplateId(templateId) + setIsTemplateDialogOpen(true) + }, []) + if (!canLoad) { return ( @@ -288,7 +323,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat } return ( -
+
Templates existentes @@ -305,189 +340,205 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat placeholder="Todas as empresas" triggerClassName="h-9 w-full min-w-56 rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800 md:w-64" /> -
{templateOptions.length === 0 ? (

- Nenhum template cadastrado ainda. Crie um template para padronizar as colunas das exportações de inventário. + Nenhum template cadastrado ainda. Clique em "Novo template" para criar o primeiro modelo de exportação.

) : (
- {templateOptions.map((tpl) => ( -
- - ))} + + ) + })}
)}
- - - - {selectedTemplateId === "new" ? "Novo template" : "Editar template"} - - - Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica. - - - -
- - setName(event.target.value)} - placeholder="Ex.: Inventário padrão, Inventário reduzido..." - className="h-9 rounded-lg border-slate-300 text-sm" - /> -
-
- -