feat: enhance visit scheduling and closing flow

This commit is contained in:
Esdras Renan 2025-11-18 17:42:38 -03:00
parent a7f9191e1d
commit 6473e8d40f
5 changed files with 577 additions and 243 deletions

View file

@ -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({ export const changeQueue = mutation({
args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") }, args: { ticketId: v.id("tickets"), queueId: v.id("queues"), actorId: v.id("users") },
handler: async (ctx, { ticketId, queueId, actorId }) => { handler: async (ctx, { ticketId, queueId, actorId }) => {

View file

@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel" import type { Id } from "@/convex/_generated/dataModel"
@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox" 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 { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -45,12 +46,15 @@ type ReportTemplatesManagerProps = {
tenantId?: string | null 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) { export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplatesManagerProps) {
const { convexUserId, session, isAdmin } = useAuth() const { convexUserId, session, isAdmin } = useAuth()
const tenantId = tenantIdProp ?? session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = tenantIdProp ?? session?.user.tenantId ?? DEFAULT_TENANT_ID
const [filterCompanyId, setFilterCompanyId] = useState<string>("all") const [filterCompanyId, setFilterCompanyId] = useState<string>("all")
const [selectedTemplateId, setSelectedTemplateId] = useState<string | "new">("new") const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null)
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false)
const canLoad = Boolean(convexUserId && isAdmin) const canLoad = Boolean(convexUserId && isAdmin)
@ -135,25 +139,30 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const selectedTemplate = useMemo( const editingTemplate = useMemo(
() => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null, () => templateOptions.find((tpl) => tpl.id === editingTemplateId) ?? null,
[templateOptions, selectedTemplateId] [templateOptions, editingTemplateId]
) )
useEffect(() => { const resetForm = useCallback(() => {
if (!templates) return
if (selectedTemplateId === "new") {
setName("") setName("")
setDescription("") setDescription("")
setTargetCompanyId("all") setTargetCompanyId("all")
setIsDefault(false) setIsDefault(false)
setIsActive(true) setIsActive(true)
setSelectedColumns(DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key)) setSelectedColumns([...DEFAULT_COLUMN_KEYS])
}, [])
useEffect(() => {
if (!isTemplateDialogOpen) return
if (!editingTemplateId) {
resetForm()
return return
} }
const tpl = templateOptions.find((t) => t.id === selectedTemplateId) const tpl = templateOptions.find((t) => t.id === editingTemplateId)
if (!tpl) { if (!tpl) {
setSelectedTemplateId("new") setEditingTemplateId(null)
resetForm()
return return
} }
setName(tpl.name) setName(tpl.name)
@ -162,8 +171,8 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
setIsDefault(Boolean(tpl.isDefault)) setIsDefault(Boolean(tpl.isDefault))
setIsActive(tpl.isActive !== false) setIsActive(tpl.isActive !== false)
const columnKeys = (tpl.columns ?? []).map((col) => col.key) const columnKeys = (tpl.columns ?? []).map((col) => col.key)
setSelectedColumns(columnKeys.length > 0 ? columnKeys : []) setSelectedColumns(columnKeys.length > 0 ? columnKeys : [...DEFAULT_COLUMN_KEYS])
}, [templates, templateOptions, selectedTemplateId]) }, [editingTemplateId, isTemplateDialogOpen, resetForm, templateOptions])
const handleToggleColumn = (key: string, checked: boolean) => { const handleToggleColumn = (key: string, checked: boolean) => {
setSelectedColumns((prev) => { setSelectedColumns((prev) => {
@ -208,7 +217,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
try { try {
setIsSaving(true) setIsSaving(true)
if (!selectedTemplate || selectedTemplateId === "new") { if (!editingTemplate) {
await createTemplate({ await createTemplate({
tenantId, tenantId,
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
@ -224,20 +233,20 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
await updateTemplate({ await updateTemplate({
tenantId, tenantId,
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
templateId: selectedTemplate.id as Id<"deviceExportTemplates">, templateId: editingTemplate.id as Id<"deviceExportTemplates">,
name: trimmedName, name: trimmedName,
description: description.trim() || undefined, description: description.trim() || undefined,
columns: columnsPayload, columns: columnsPayload,
filters: selectedTemplate.filters ?? undefined, filters: editingTemplate.filters ?? undefined,
companyId: companyIdValue, companyId: companyIdValue,
isDefault, isDefault,
isActive, isActive,
}) })
} }
toast.success("Template salvo com sucesso.") toast.success("Template salvo com sucesso.")
if (selectedTemplateId === "new") { setIsTemplateDialogOpen(false)
setSelectedTemplateId("new") setEditingTemplateId(null)
} resetForm()
} catch (error) { } catch (error) {
console.error("[report-templates] Failed to save template", error) console.error("[report-templates] Failed to save template", error)
toast.error("Não foi possível salvar o template.") toast.error("Não foi possível salvar o template.")
@ -247,16 +256,18 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!convexUserId || !selectedTemplate) return if (!convexUserId || !editingTemplate) return
try { try {
setIsDeleting(true) setIsDeleting(true)
await removeTemplate({ await removeTemplate({
tenantId, tenantId,
actorId: convexUserId as Id<"users">, actorId: convexUserId as Id<"users">,
templateId: selectedTemplate.id as Id<"deviceExportTemplates">, templateId: editingTemplate.id as Id<"deviceExportTemplates">,
}) })
toast.success("Template removido com sucesso.") toast.success("Template removido com sucesso.")
setSelectedTemplateId("new") setIsTemplateDialogOpen(false)
setEditingTemplateId(null)
resetForm()
} catch (error) { } catch (error) {
console.error("[report-templates] Failed to delete template", error) console.error("[report-templates] Failed to delete template", error)
toast.error("Não foi possível remover o template.") 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) { if (!canLoad) {
return ( return (
<Card className="border-slate-200"> <Card className="border-slate-200">
@ -288,7 +323,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
} }
return ( return (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1.6fr)]"> <div className="space-y-6">
<Card className="border-slate-200 rounded-2xl shadow-sm"> <Card className="border-slate-200 rounded-2xl shadow-sm">
<CardHeader> <CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Templates existentes</CardTitle> <CardTitle className="text-lg font-semibold text-neutral-900">Templates existentes</CardTitle>
@ -305,67 +340,84 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
placeholder="Todas as empresas" 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" 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"
/> />
<Button <Button size="sm" className="gap-2" onClick={handleOpenCreateDialog}>
size="sm"
variant={selectedTemplateId === "new" ? "default" : "outline"}
onClick={() => setSelectedTemplateId("new")}
>
Novo template Novo template
</Button> </Button>
</div> </div>
{templateOptions.length === 0 ? ( {templateOptions.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500"> <p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Nenhum template cadastrado ainda. Crie um template para padronizar as colunas das exportações de inventário. Nenhum template cadastrado ainda. Clique em &quot;Novo template&quot; para criar o primeiro modelo de exportação.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{templateOptions.map((tpl) => ( {templateOptions.map((tpl) => {
const isActiveTemplate = isTemplateDialogOpen && editingTemplateId === tpl.id
const columnCount = tpl.columns?.length ?? 0
return (
<button <button
key={tpl.id} key={tpl.id}
type="button" type="button"
onClick={() => setSelectedTemplateId(tpl.id)} onClick={() => handleTemplateRowClick(tpl.id)}
className={cn( className={cn(
"flex w-full items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition", "group flex w-full flex-col gap-2 rounded-xl border px-4 py-3 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-500 focus-visible:ring-offset-2",
selectedTemplateId === tpl.id isActiveTemplate
? "border-black bg-black text-white" ? "border-neutral-900 bg-neutral-950 text-white shadow-[0_10px_25px_rgba(15,23,42,0.35)] hover:bg-neutral-900"
: "border-slate-200 bg-white text-neutral-800 hover:border-slate-300 hover:bg-slate-50" : "border-slate-200 bg-white text-neutral-900 hover:border-slate-300 hover:bg-slate-50"
)} )}
> >
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-semibold">{tpl.name}</span> <span className={cn("font-semibold", isActiveTemplate ? "text-white" : "text-neutral-900")}>{tpl.name}</span>
<span className="text-xs text-neutral-500"> <span className={cn("text-xs", isActiveTemplate ? "text-white/70" : "text-neutral-500")}>
{tpl.companyId ? "Template específico por empresa" : "Template global"} {tpl.companyId ? "Template específico por empresa" : "Template global"} · {columnCount}{" "}
{columnCount === 1 ? "coluna" : "colunas"}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{tpl.isDefault ? ( {tpl.isDefault ? (
<Badge variant={selectedTemplateId === tpl.id ? "outline" : "secondary"} className="text-[10px] uppercase"> <Badge
variant="outline"
className={cn(
"text-[10px] uppercase",
isActiveTemplate ? "border-white/40 text-white hover:bg-white/5" : "border-slate-200 bg-slate-50 text-slate-700"
)}
>
Padrão Padrão
</Badge> </Badge>
) : null} ) : null}
{tpl.isActive === false ? ( {tpl.isActive === false ? (
<Badge variant="outline" className="text-[10px] uppercase text-amber-700"> <Badge
variant="outline"
className={cn(
"text-[10px] uppercase",
isActiveTemplate ? "border-white/40 text-amber-100" : "border-amber-200 bg-amber-50 text-amber-800"
)}
>
Inativo Inativo
</Badge> </Badge>
) : null} ) : null}
</div> </div>
</div>
{tpl.description ? (
<p className={cn("text-xs leading-relaxed", isActiveTemplate ? "text-white/80" : "text-neutral-500")}>{tpl.description}</p>
) : null}
</button> </button>
))} )
})}
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-slate-200 rounded-2xl shadow-sm"> <Dialog open={isTemplateDialogOpen} onOpenChange={handleTemplateDialogOpenChange}>
<CardHeader> <DialogContent className="max-w-3xl">
<CardTitle className="text-lg font-semibold text-neutral-900"> <DialogHeader>
{selectedTemplateId === "new" ? "Novo template" : "Editar template"} <DialogTitle>{editingTemplate ? "Editar template" : "Novo template"}</DialogTitle>
</CardTitle> <DialogDescription>
<CardDescription className="text-neutral-600">
Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica. Defina o conjunto de colunas e, opcionalmente, vincule o template a uma empresa específica.
</CardDescription> </DialogDescription>
</CardHeader> </DialogHeader>
<CardContent className="space-y-6"> <div className="space-y-6">
<div className="space-y-3"> <div className="space-y-3">
<label className="text-sm font-medium text-neutral-800"> <label className="text-sm font-medium text-neutral-800">
Nome do template <span className="text-destructive">*</span> Nome do template <span className="text-destructive">*</span>
@ -411,10 +463,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
<span>Usar como padrão para este escopo</span> <span>Usar como padrão para este escopo</span>
</label> </label>
<label className="flex items-center gap-2 text-xs text-neutral-700"> <label className="flex items-center gap-2 text-xs text-neutral-700">
<Checkbox <Checkbox checked={isActive} onCheckedChange={(v) => setIsActive(Boolean(v))} />
checked={isActive}
onCheckedChange={(v) => setIsActive(Boolean(v))}
/>
<span>Template ativo</span> <span>Template ativo</span>
</label> </label>
</div> </div>
@ -422,13 +471,13 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium text-neutral-800">Colunas incluídas</span> <span className="text-sm font-medium text-neutral-800">Colunas incluídas</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={handleClearColumns}> <Button type="button" size="sm" variant="outline" onClick={handleClearColumns} disabled={isSaving || isDeleting}>
Limpar Limpar
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={handleSelectAll}> <Button type="button" size="sm" variant="outline" onClick={handleSelectAll} disabled={isSaving || isDeleting}>
Selecionar todas Selecionar todas
</Button> </Button>
</div> </div>
@ -451,6 +500,7 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
checked={checked} checked={checked}
onCheckedChange={(v) => handleToggleColumn(col.key, Boolean(v))} onCheckedChange={(v) => handleToggleColumn(col.key, Boolean(v))}
className="size-4" className="size-4"
disabled={isSaving || isDeleting}
/> />
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{col.label} {col.label}
@ -461,33 +511,34 @@ export function ReportTemplatesManager({ tenantId: tenantIdProp }: ReportTemplat
}) })
)} )}
</div> </div>
<p className="text-xs text-neutral-500">{selectedColumns.length} coluna{selectedColumns.length === 1 ? "" : "s"} selecionada{selectedColumns.length === 1 ? "" : "s"}.</p>
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div className="flex items-center gap-2 text-xs text-neutral-500">
<span>{selectedColumns.length} colunas selecionadas</span>
<span>·</span>
<span>{templateOptions.length} templates cadastrados</span>
</div> </div>
<div className="flex items-center gap-2"> <DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{selectedTemplate && selectedTemplateId !== "new" ? ( {editingTemplate ? (
<Button <Button
type="button" type="button"
size="sm"
variant="outline" variant="outline"
onClick={handleDelete} onClick={handleDelete}
disabled={isDeleting} disabled={isDeleting || isSaving}
className="text-destructive"
> >
{isDeleting ? "Removendo..." : "Remover"} {isDeleting ? "Removendo..." : "Remover template"}
</Button> </Button>
) : null} ) : (
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}> <span className="text-xs text-neutral-500">Após salvar, o template ficará visível na lista.</span>
{isSaving ? "Salvando..." : "Salvar template"} )}
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => handleTemplateDialogOpenChange(false)} disabled={isSaving || isDeleting}>
Cancelar
</Button>
<Button type="button" onClick={handleSave} disabled={isSaving || isDeleting}>
{isSaving ? "Salvando..." : editingTemplate ? "Salvar mudanças" : "Criar template"}
</Button> </Button>
</div> </div>
</div> </DialogFooter>
</CardContent> </DialogContent>
</Card> </Dialog>
</div> </div>
) )
} }

View file

@ -32,6 +32,12 @@ const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
const DEFAULT_COMPANY_NAME = "Rever Tecnologia" const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim())) const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim()))
const htmlToPlainText = (value: string) =>
value
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/\s+/g, " ")
.trim()
export type AdjustWorkSummaryResult = { export type AdjustWorkSummaryResult = {
ticketId: Id<"tickets"> ticketId: Id<"tickets">
@ -205,6 +211,9 @@ export function CloseTicketDialog({
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null) const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [message, setMessage] = useState<string>("") const [message, setMessage] = useState<string>("")
const [messageWarning, setMessageWarning] = useState(false)
const messagePlainText = useMemo(() => htmlToPlainText(message ?? ""), [message])
const hasMessage = messagePlainText.length > 0
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [shouldAdjustTime, setShouldAdjustTime] = useState<boolean>(false) const [shouldAdjustTime, setShouldAdjustTime] = useState<boolean>(false)
const [internalHours, setInternalHours] = useState<string>("0") const [internalHours, setInternalHours] = useState<string>("0")
@ -228,6 +237,12 @@ export function CloseTicketDialog({
const draftStorageKey = useMemo(() => `${DRAFT_STORAGE_PREFIX}${ticketId}`, [ticketId]) const draftStorageKey = useMemo(() => `${DRAFT_STORAGE_PREFIX}${ticketId}`, [ticketId])
useEffect(() => {
if (messageWarning && hasMessage) {
setMessageWarning(false)
}
}, [hasMessage, messageWarning])
const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim() const digitsOnlyReference = linkedReference.replace(/[^0-9]/g, "").trim()
const normalizedReference = useMemo(() => { const normalizedReference = useMemo(() => {
@ -540,7 +555,14 @@ export function CloseTicketDialog({
setCurrentStep(index) setCurrentStep(index)
} }
const goToNextStep = () => setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1)) const goToNextStep = useCallback(() => {
if (currentStep === 0 && !hasMessage) {
setMessageWarning(true)
toast.error("Escreva uma mensagem de encerramento antes de continuar.")
return
}
setCurrentStep((prev) => Math.min(prev + 1, WIZARD_STEPS.length - 1))
}, [currentStep, hasMessage])
const goToPreviousStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0)) const goToPreviousStep = () => setCurrentStep((prev) => Math.max(prev - 1, 0))
const isLastStep = currentStep === WIZARD_STEPS.length - 1 const isLastStep = currentStep === WIZARD_STEPS.length - 1
@ -930,16 +952,27 @@ export function CloseTicketDialog({
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div
className={cn(
"space-y-2 rounded-2xl border px-4 py-3 transition",
messageWarning ? "border-amber-500 bg-amber-50/70 shadow-[0_0_0_1px_rgba(251,191,36,0.4)]" : "border-transparent bg-transparent"
)}
>
<p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p> <p className="text-sm font-medium text-neutral-800">Mensagem de encerramento</p>
<RichTextEditor <RichTextEditor
value={message} value={message}
onChange={setMessage} onChange={setMessage}
minHeight={220} minHeight={220}
placeholder="Escreva uma mensagem final para o cliente..." placeholder="Descreva o encerramento para o cliente..."
/> />
<p className="text-xs text-neutral-500"> <p
Você pode editar o conteúdo antes de enviar. Deixe em branco para encerrar sem comentário adicional. O comentário será público e ficará registrado no histórico do ticket. className={cn(
"text-xs",
messageWarning ? "font-semibold text-amber-700" : "text-neutral-500"
)}
>
Este texto é enviado ao cliente e é obrigatório para encerrar o ticket.
{messageWarning ? " Digite uma mensagem para prosseguir." : ""}
</p> </p>
</div> </div>
</div> </div>

View file

@ -35,6 +35,7 @@ import { priorityStyles } from "@/lib/ticket-priority-style"
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers" import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types" import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
import { Calendar as CalendarIcon } from "lucide-react" import { Calendar as CalendarIcon } from "lucide-react"
import { TimePicker } from "@/components/ui/time-picker"
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers" import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
type TriggerVariant = "button" | "card" type TriggerVariant = "button" | "card"
@ -119,6 +120,7 @@ const schema = z.object({
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"), channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
queueName: z.string().nullable().optional(), queueName: z.string().nullable().optional(),
visitDate: z.string().nullable().optional(), visitDate: z.string().nullable().optional(),
visitTime: z.string().nullable().optional(),
assigneeId: z.string().nullable().optional(), assigneeId: z.string().nullable().optional(),
companyId: z.string().optional(), companyId: z.string().optional(),
requesterId: z.string().min(1, "Selecione um solicitante"), requesterId: z.string().min(1, "Selecione um solicitante"),
@ -144,6 +146,8 @@ export function NewTicketDialog({
priority: "MEDIUM", priority: "MEDIUM",
channel: "MANUAL", channel: "MANUAL",
queueName: null, queueName: null,
visitDate: null,
visitTime: null,
assigneeId: null, assigneeId: null,
companyId: NO_COMPANY_VALUE, companyId: NO_COMPANY_VALUE,
requesterId: "", requesterId: "",
@ -286,6 +290,7 @@ export function NewTicketDialog({
const priorityValue = form.watch("priority") as TicketPriority const priorityValue = form.watch("priority") as TicketPriority
const queueValue = form.watch("queueName") ?? "NONE" const queueValue = form.watch("queueName") ?? "NONE"
const visitDateValue = form.watch("visitDate") ?? null const visitDateValue = form.watch("visitDate") ?? null
const visitTimeValue = form.watch("visitTime") ?? null
const assigneeValue = form.watch("assigneeId") ?? null const assigneeValue = form.watch("assigneeId") ?? null
const assigneeSelectValue = assigneeValue ?? "NONE" const assigneeSelectValue = assigneeValue ?? "NONE"
const requesterValue = form.watch("requesterId") ?? "" const requesterValue = form.watch("requesterId") ?? ""
@ -301,12 +306,21 @@ export function NewTicketDialog({
) )
const visitDate = useMemo(() => { const visitDate = useMemo(() => {
if (!visitDateValue) return null if (!visitDateValue) return null
const timeSegment =
typeof visitTimeValue === "string" && visitTimeValue.length > 0 ? visitTimeValue : "00:00"
const normalizedTime = timeSegment.length === 5 ? `${timeSegment}:00` : timeSegment
try { try {
return parseISO(visitDateValue) return parseISO(`${visitDateValue}T${normalizedTime}`)
} catch { } catch {
return null return null
} }
}, [visitDateValue]) }, [visitDateValue, visitTimeValue])
useEffect(() => {
if (isVisitQueue) return
form.setValue("visitDate", null, { shouldDirty: false, shouldTouch: false })
form.setValue("visitTime", null, { shouldDirty: false, shouldTouch: false })
}, [form, isVisitQueue])
const companyOptions = useMemo(() => { const companyOptions = useMemo(() => {
const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>() const map = new Map<string, { id: string; name: string; isAvulso?: boolean; keywords: string[] }>()
@ -532,6 +546,10 @@ export function NewTicketDialog({
form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." }) form.setError("visitDate", { type: "custom", message: "Informe a data da visita para chamados desta fila." })
return return
} }
if (!values.visitTime) {
form.setError("visitTime", { type: "custom", message: "Informe o horário da visita para chamados desta fila." })
return
}
} }
let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = [] let customFieldsPayload: Array<{ fieldId: Id<"ticketFields">; value: unknown }> = []
@ -551,9 +569,10 @@ export function NewTicketDialog({
const selectedAssignee = form.getValues("assigneeId") ?? null const selectedAssignee = form.getValues("assigneeId") ?? null
const requesterToSend = values.requesterId as Id<"users"> const requesterToSend = values.requesterId as Id<"users">
let visitDateTimestamp: number | undefined let visitDateTimestamp: number | undefined
if (isVisitQueueOnSubmit && values.visitDate) { if (isVisitQueueOnSubmit && values.visitDate && values.visitTime) {
try { try {
const parsed = parseISO(values.visitDate) const timeSegment = values.visitTime.length === 5 ? `${values.visitTime}:00` : values.visitTime
const parsed = parseISO(`${values.visitDate}T${timeSegment}`)
visitDateTimestamp = parsed.getTime() visitDateTimestamp = parsed.getTime()
} catch { } catch {
visitDateTimestamp = undefined visitDateTimestamp = undefined
@ -604,6 +623,7 @@ export function NewTicketDialog({
categoryId: "", categoryId: "",
subcategoryId: "", subcategoryId: "",
visitDate: null, visitDate: null,
visitTime: null,
}) })
form.clearErrors() form.clearErrors()
setSelectedFormKey("default") setSelectedFormKey("default")
@ -960,8 +980,10 @@ export function NewTicketDialog({
{isVisitQueue ? ( {isVisitQueue ? (
<Field> <Field>
<FieldLabel className="flex items-center gap-1"> <FieldLabel className="flex items-center gap-1">
Data da visita <span className="text-destructive">*</span> Data e horário da visita <span className="text-destructive">*</span>
</FieldLabel> </FieldLabel>
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px]">
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}> <Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
@ -988,6 +1010,11 @@ export function NewTicketDialog({
shouldTouch: true, shouldTouch: true,
shouldValidate: form.formState.isSubmitted, shouldValidate: form.formState.isSubmitted,
}) })
form.setValue("visitTime", null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
return return
} }
const iso = date.toISOString().slice(0, 10) const iso = date.toISOString().slice(0, 10)
@ -1002,6 +1029,28 @@ export function NewTicketDialog({
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<div className="flex flex-col gap-1">
<TimePicker
value={typeof visitTimeValue === "string" ? visitTimeValue : ""}
onChange={(value) =>
form.setValue("visitTime", value || null, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: form.formState.isSubmitted,
})
}
stepMinutes={5}
className="h-8 rounded-full border border-slate-300 bg-white text-sm font-medium text-neutral-800 shadow-sm"
/>
<FieldError
errors={
form.formState.errors.visitTime
? [{ message: form.formState.errors.visitTime.message as string }]
: []
}
/>
</div>
</div>
<FieldError <FieldError
errors={ errors={
form.formState.errors.visitDate form.formState.errors.visitDate
@ -1009,6 +1058,7 @@ export function NewTicketDialog({
: [] : []
} }
/> />
</div>
</Field> </Field>
) : null} ) : null}
<Field> <Field>

View file

@ -2,7 +2,7 @@
import Link from "next/link" import Link from "next/link"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow } from "date-fns" import { format, formatDistanceToNow, parseISO } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconLink, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react" import { IconClock, IconDownload, IconLink, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react" import { useMutation, useQuery } from "convex/react"
@ -19,7 +19,7 @@ import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select" import { StatusSelect } from "@/components/tickets/status-select"
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog" import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields" import { TicketCustomFieldsSection } from "@/components/tickets/ticket-custom-fields"
import { CheckCircle2, RotateCcw } from "lucide-react" import { Calendar as CalendarIcon, CheckCircle2, RotateCcw } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
import { useTicketCategories } from "@/hooks/use-ticket-categories" import { useTicketCategories } from "@/hooks/use-ticket-categories"
import { useDefaultQueues } from "@/hooks/use-default-queues" import { useDefaultQueues } from "@/hooks/use-default-queues"
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -42,6 +43,9 @@ import {
type SessionStartOrigin, type SessionStartOrigin,
} from "./ticket-timer.utils" } from "./ticket-timer.utils"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { TimePicker } from "@/components/ui/time-picker"
interface TicketHeaderProps { interface TicketHeaderProps {
ticket: TicketWithDetails ticket: TicketWithDetails
@ -197,7 +201,32 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const pauseWork = useMutation(api.tickets.pauseWork) const pauseWork = useMutation(api.tickets.pauseWork)
const updateCategories = useMutation(api.tickets.updateCategories) const updateCategories = useMutation(api.tickets.updateCategories)
const reopenTicket = useMutation(api.tickets.reopenTicket) const reopenTicket = useMutation(api.tickets.reopenTicket)
const updateVisitSchedule = useMutation(api.tickets.updateVisitSchedule)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? [] const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const normalizedTicketQueue = useMemo(() => (ticket.queue ?? "").toLowerCase(), [ticket.queue])
const isVisitQueueTicket = useMemo(
() => VISIT_KEYWORDS.some((keyword) => normalizedTicketQueue.includes(keyword)),
[normalizedTicketQueue],
)
const initialVisitDateValue = useMemo(
() => (ticket.dueAt ? format(ticket.dueAt, "yyyy-MM-dd") : null),
[ticket.dueAt],
)
const initialVisitTimeValue = useMemo(
() => (ticket.dueAt ? format(ticket.dueAt, "HH:mm") : null),
[ticket.dueAt],
)
const [visitDateInput, setVisitDateInput] = useState<string | null>(initialVisitDateValue)
const [visitTimeInput, setVisitTimeInput] = useState<string | null>(initialVisitTimeValue)
const [visitDatePickerOpen, setVisitDatePickerOpen] = useState(false)
const [visitError, setVisitError] = useState<string | null>(null)
const visitDirtyRef = useMemo(
() =>
isVisitQueueTicket &&
(visitDateInput !== initialVisitDateValue || visitTimeInput !== initialVisitTimeValue),
[isVisitQueueTicket, visitDateInput, initialVisitDateValue, visitTimeInput, initialVisitTimeValue],
)
const visitDirty = visitDirtyRef
const queuesEnabled = Boolean(isStaff && convexUserId) const queuesEnabled = Boolean(isStaff && convexUserId)
const companiesRemote = useQuery( const companiesRemote = useQuery(
api.companies.list, api.companies.list,
@ -284,6 +313,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id) const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
const [requesterError, setRequesterError] = useState<string | null>(null) const [requesterError, setRequesterError] = useState<string | null>(null)
const [customersInitialized, setCustomersInitialized] = useState(false) const [customersInitialized, setCustomersInitialized] = useState(false)
const visitDatePickerValue = useMemo(() => {
if (!visitDateInput) return null
try {
return parseISO(`${visitDateInput}T00:00:00`)
} catch {
return null
}
}, [visitDateInput])
const selectedCategoryId = categorySelection.categoryId const selectedCategoryId = categorySelection.categoryId
const selectedSubcategoryId = categorySelection.subcategoryId const selectedSubcategoryId = categorySelection.subcategoryId
const dirty = useMemo(() => subject !== ticket.subject, [subject, ticket.subject]) const dirty = useMemo(() => subject !== ticket.subject, [subject, ticket.subject])
@ -386,10 +423,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}, [companySelection, customers]) }, [companySelection, customers])
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId]) const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id]) const requesterDirty = useMemo(() => requesterSelection !== ticket.requester.id, [requesterSelection, ticket.requester.id])
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty const visitHasInvalid =
isVisitQueueTicket && visitDirty && (!visitDateInput || !visitTimeInput || Boolean(visitError))
const formDirty = dirty || categoryDirty || queueDirty || assigneeDirty || requesterDirty || visitDirty
const normalizedAssigneeReason = assigneeChangeReason.trim() const normalizedAssigneeReason = assigneeChangeReason.trim()
const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5 const assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
const saveDisabled = !formDirty || saving || !assigneeReasonValid const saveDisabled = !formDirty || saving || !assigneeReasonValid || visitHasInvalid
const companyLabel = useMemo(() => { const companyLabel = useMemo(() => {
if (ticket.company?.name) return ticket.company.name if (ticket.company?.name) return ticket.company.name
if (isAvulso) return "Cliente avulso" if (isAvulso) return "Cliente avulso"
@ -520,6 +559,39 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setQueueSelection(currentQueueName) setQueueSelection(currentQueueName)
} }
if (isVisitQueueTicket && visitDirty && !isManager) {
if (!visitDateInput || !visitTimeInput) {
setVisitError("Informe a data e o horário da visita.")
throw new Error("invalid-visit-schedule")
}
try {
const timeSegment = visitTimeInput.length === 5 ? `${visitTimeInput}:00` : visitTimeInput
const isoString = `${visitDateInput}T${timeSegment}`
const parsed = parseISO(isoString)
const timestamp = parsed.getTime()
if (!Number.isFinite(timestamp)) {
setVisitError("Data ou horário da visita inválido.")
throw new Error("invalid-visit-schedule")
}
setVisitError(null)
toast.loading("Atualizando data da visita...", { id: "visit-date" })
await updateVisitSchedule({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
visitDate: timestamp,
})
toast.success("Data da visita atualizada!", { id: "visit-date" })
} catch (visitScheduleError) {
if (!(visitScheduleError instanceof Error && visitScheduleError.message === "invalid-visit-schedule")) {
toast.error("Não foi possível atualizar a data da visita.", { id: "visit-date" })
}
throw visitScheduleError
}
} else if (isVisitQueueTicket && visitDirty && isManager) {
setVisitDateInput(initialVisitDateValue)
setVisitTimeInput(initialVisitTimeValue)
}
if (requesterDirty && !isManager) { if (requesterDirty && !isManager) {
if (!requesterSelection) { if (!requesterSelection) {
setRequesterError("Selecione um solicitante.") setRequesterError("Selecione um solicitante.")
@ -634,6 +706,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setAssigneeSelection(currentAssigneeId) setAssigneeSelection(currentAssigneeId)
setAssigneeChangeReason("") setAssigneeChangeReason("")
setAssigneeReasonError(null) setAssigneeReasonError(null)
setVisitDateInput(initialVisitDateValue)
setVisitTimeInput(initialVisitTimeValue)
setVisitError(null)
setEditing(false) setEditing(false)
} }
@ -651,6 +726,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setCompanySelection(currentCompanySelection) setCompanySelection(currentCompanySelection)
}, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId, ticket.requester.id, currentCompanySelection]) }, [editing, ticket.category?.id, ticket.subcategory?.id, ticket.queue, currentAssigneeId, ticket.requester.id, currentCompanySelection])
useEffect(() => {
if (editing) return
setVisitDateInput(initialVisitDateValue)
setVisitTimeInput(initialVisitTimeValue)
setVisitError(null)
}, [editing, initialVisitDateValue, initialVisitTimeValue])
useEffect(() => {
if (!editing) {
setVisitDatePickerOpen(false)
}
}, [editing])
useEffect(() => {
if (visitError && visitDateInput && visitTimeInput) {
setVisitError(null)
}
}, [visitError, visitDateInput, visitTimeInput])
useEffect(() => { useEffect(() => {
if (!editing) return if (!editing) return
if (!selectedCategoryId) { if (!selectedCategoryId) {
@ -1548,7 +1642,64 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<span className={subtleBadgeClass}>{updatedRelative}</span> <span className={subtleBadgeClass}>{updatedRelative}</span>
</div> </div>
</div> </div>
{ticket.dueAt ? ( {isVisitQueueTicket ? (
<div className="flex flex-col gap-1">
<span className={sectionLabelClass}>Data da visita</span>
{editing && !isManager ? (
<div className="space-y-2">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px]">
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={`flex h-9 w-full items-center justify-between rounded-lg border border-slate-300 bg-white px-3 text-left text-sm shadow-sm hover:bg-slate-50 ${visitDateInput ? "text-neutral-800" : "text-muted-foreground"}`}
>
{visitDatePickerValue
? format(visitDatePickerValue, "dd/MM/yyyy", { locale: ptBR })
: "Selecionar data"}
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={visitDatePickerValue ?? undefined}
onSelect={(date) => {
if (!date) {
setVisitDateInput(null)
setVisitTimeInput(null)
return
}
const iso = date.toISOString().slice(0, 10)
setVisitDateInput(iso)
setVisitError(null)
setVisitDatePickerOpen(false)
}}
initialFocus
/>
</PopoverContent>
</Popover>
<TimePicker
value={visitTimeInput ?? ""}
onChange={(value) => {
setVisitTimeInput(value || null)
if (value) {
setVisitError(null)
}
}}
stepMinutes={5}
className="h-9 rounded-lg border border-slate-300 bg-white text-sm font-medium text-neutral-800 shadow-sm"
/>
</div>
{visitError ? <p className="text-xs font-semibold text-rose-600">{visitError}</p> : null}
</div>
) : (
<span className={sectionValueClass}>
{ticket.dueAt ? format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR }) : "Sem data definida"}
</span>
)}
</div>
) : ticket.dueAt ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className={sectionLabelClass}>SLA até</span> <span className={sectionLabelClass}>SLA até</span>
<span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span> <span className={sectionValueClass}>{format(ticket.dueAt, "dd/MM/yyyy HH:mm", { locale: ptBR })}</span>