feat: enhance visit scheduling and closing flow
This commit is contained in:
parent
a7f9191e1d
commit
6473e8d40f
5 changed files with 577 additions and 243 deletions
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const resetForm = useCallback(() => {
|
||||||
|
setName("")
|
||||||
|
setDescription("")
|
||||||
|
setTargetCompanyId("all")
|
||||||
|
setIsDefault(false)
|
||||||
|
setIsActive(true)
|
||||||
|
setSelectedColumns([...DEFAULT_COLUMN_KEYS])
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!templates) return
|
if (!isTemplateDialogOpen) return
|
||||||
if (selectedTemplateId === "new") {
|
if (!editingTemplateId) {
|
||||||
setName("")
|
resetForm()
|
||||||
setDescription("")
|
|
||||||
setTargetCompanyId("all")
|
|
||||||
setIsDefault(false)
|
|
||||||
setIsActive(true)
|
|
||||||
setSelectedColumns(DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map((meta) => meta.key))
|
|
||||||
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,189 +340,205 @@ 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 "Novo template" 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) => {
|
||||||
<button
|
const isActiveTemplate = isTemplateDialogOpen && editingTemplateId === tpl.id
|
||||||
key={tpl.id}
|
const columnCount = tpl.columns?.length ?? 0
|
||||||
type="button"
|
return (
|
||||||
onClick={() => setSelectedTemplateId(tpl.id)}
|
<button
|
||||||
className={cn(
|
key={tpl.id}
|
||||||
"flex w-full items-center justify-between rounded-xl border px-3 py-2 text-left text-sm transition",
|
type="button"
|
||||||
selectedTemplateId === tpl.id
|
onClick={() => handleTemplateRowClick(tpl.id)}
|
||||||
? "border-black bg-black text-white"
|
className={cn(
|
||||||
: "border-slate-200 bg-white text-neutral-800 hover:border-slate-300 hover:bg-slate-50"
|
"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",
|
||||||
)}
|
isActiveTemplate
|
||||||
>
|
? "border-neutral-900 bg-neutral-950 text-white shadow-[0_10px_25px_rgba(15,23,42,0.35)] hover:bg-neutral-900"
|
||||||
<div className="flex flex-col">
|
: "border-slate-200 bg-white text-neutral-900 hover:border-slate-300 hover:bg-slate-50"
|
||||||
<span className="font-semibold">{tpl.name}</span>
|
)}
|
||||||
<span className="text-xs text-neutral-500">
|
>
|
||||||
{tpl.companyId ? "Template específico por empresa" : "Template global"}
|
<div className="flex items-start justify-between gap-3">
|
||||||
</span>
|
<div className="flex flex-col">
|
||||||
</div>
|
<span className={cn("font-semibold", isActiveTemplate ? "text-white" : "text-neutral-900")}>{tpl.name}</span>
|
||||||
<div className="flex items-center gap-2">
|
<span className={cn("text-xs", isActiveTemplate ? "text-white/70" : "text-neutral-500")}>
|
||||||
{tpl.isDefault ? (
|
{tpl.companyId ? "Template específico por empresa" : "Template global"} · {columnCount}{" "}
|
||||||
<Badge variant={selectedTemplateId === tpl.id ? "outline" : "secondary"} className="text-[10px] uppercase">
|
{columnCount === 1 ? "coluna" : "colunas"}
|
||||||
Padrão
|
</span>
|
||||||
</Badge>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{tpl.isDefault ? (
|
||||||
|
<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
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{tpl.isActive === false ? (
|
||||||
|
<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
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tpl.description ? (
|
||||||
|
<p className={cn("text-xs leading-relaxed", isActiveTemplate ? "text-white/80" : "text-neutral-500")}>{tpl.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{tpl.isActive === false ? (
|
</button>
|
||||||
<Badge variant="outline" className="text-[10px] uppercase text-amber-700">
|
)
|
||||||
Inativo
|
})}
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</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.
|
</DialogDescription>
|
||||||
</CardDescription>
|
</DialogHeader>
|
||||||
</CardHeader>
|
<div className="space-y-6">
|
||||||
<CardContent 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>
|
</label>
|
||||||
</label>
|
<Input
|
||||||
<Input
|
value={name}
|
||||||
value={name}
|
onChange={(event) => setName(event.target.value)}
|
||||||
onChange={(event) => setName(event.target.value)}
|
placeholder="Ex.: Inventário padrão, Inventário reduzido..."
|
||||||
placeholder="Ex.: Inventário padrão, Inventário reduzido..."
|
className="h-9 rounded-lg border-slate-300 text-sm"
|
||||||
className="h-9 rounded-lg border-slate-300 text-sm"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="text-sm font-medium text-neutral-800">Descrição</label>
|
|
||||||
<Textarea
|
|
||||||
value={description}
|
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
|
||||||
placeholder="Contexto de uso, filtros sugeridos, etc."
|
|
||||||
rows={3}
|
|
||||||
className="rounded-lg border-slate-300 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-neutral-800">Escopo do template</label>
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
|
||||||
<SearchableCombobox
|
|
||||||
value={targetCompanyId}
|
|
||||||
onValueChange={(value) => setTargetCompanyId(value ?? "all")}
|
|
||||||
options={companyOptions}
|
|
||||||
placeholder="Todas as empresas"
|
|
||||||
triggerClassName="h-9 w-full rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
|
||||||
/>
|
|
||||||
<p className="mt-2 text-xs text-neutral-500">
|
|
||||||
Defina se este template será global ou específico para uma empresa.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="text-sm font-medium text-neutral-800">Opções</label>
|
<label className="text-sm font-medium text-neutral-800">Descrição</label>
|
||||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
<Textarea
|
||||||
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
value={description}
|
||||||
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(Boolean(v))} />
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
<span>Usar como padrão para este escopo</span>
|
placeholder="Contexto de uso, filtros sugeridos, etc."
|
||||||
</label>
|
rows={3}
|
||||||
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
className="rounded-lg border-slate-300 text-sm"
|
||||||
<Checkbox
|
/>
|
||||||
checked={isActive}
|
</div>
|
||||||
onCheckedChange={(v) => setIsActive(Boolean(v))}
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">Escopo do template</label>
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<SearchableCombobox
|
||||||
|
value={targetCompanyId}
|
||||||
|
onValueChange={(value) => setTargetCompanyId(value ?? "all")}
|
||||||
|
options={companyOptions}
|
||||||
|
placeholder="Todas as empresas"
|
||||||
|
triggerClassName="h-9 w-full rounded-full border border-slate-300 bg-white px-3 text-sm font-medium text-neutral-800"
|
||||||
/>
|
/>
|
||||||
<span>Template ativo</span>
|
<p className="mt-2 text-xs text-neutral-500">
|
||||||
</label>
|
Defina se este template será global ou específico para uma empresa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-neutral-800">Opções</label>
|
||||||
|
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
||||||
|
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(Boolean(v))} />
|
||||||
|
<span>Usar como padrão para este escopo</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-neutral-700">
|
||||||
|
<Checkbox checked={isActive} onCheckedChange={(v) => setIsActive(Boolean(v))} />
|
||||||
|
<span>Template ativo</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
<div className="max-h-72 space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
||||||
<div className="max-h-72 space-y-1 overflow-y-auto rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
{availableColumns.length === 0 ? (
|
||||||
{availableColumns.length === 0 ? (
|
<p className="py-2 text-xs text-neutral-500">
|
||||||
<p className="py-2 text-xs text-neutral-500">
|
Nenhuma coluna disponível. Verifique o inventário de dispositivos e os campos personalizados.
|
||||||
Nenhuma coluna disponível. Verifique o inventário de dispositivos e os campos personalizados.
|
</p>
|
||||||
</p>
|
) : (
|
||||||
) : (
|
availableColumns.map((col) => {
|
||||||
availableColumns.map((col) => {
|
const checked = selectedColumns.includes(col.key)
|
||||||
const checked = selectedColumns.includes(col.key)
|
const isCustom = col.group === "custom"
|
||||||
const isCustom = col.group === "custom"
|
return (
|
||||||
return (
|
<label
|
||||||
<label
|
key={col.key}
|
||||||
key={col.key}
|
className="flex cursor-pointer items-center gap-2 rounded-md bg-white px-2 py-1.5 text-xs text-neutral-800 shadow-[0_0_0_1px_rgba(148,163,184,0.3)] hover:bg-slate-50"
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-md bg-white px-2 py-1.5 text-xs text-neutral-800 shadow-[0_0_0_1px_rgba(148,163,184,0.3)] hover:bg-slate-50"
|
>
|
||||||
>
|
<Checkbox
|
||||||
<Checkbox
|
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}
|
||||||
{isCustom ? " (campo personalizado)" : ""}
|
{isCustom ? " (campo personalizado)" : ""}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500">{selectedColumns.length} coluna{selectedColumns.length === 1 ? "" : "s"} selecionada{selectedColumns.length === 1 ? "" : "s"}.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
{editingTemplate ? (
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-500">
|
<Button
|
||||||
<span>{selectedColumns.length} colunas selecionadas</span>
|
type="button"
|
||||||
<span>·</span>
|
variant="outline"
|
||||||
<span>{templateOptions.length} templates cadastrados</span>
|
onClick={handleDelete}
|
||||||
</div>
|
disabled={isDeleting || isSaving}
|
||||||
<div className="flex items-center gap-2">
|
className="text-destructive"
|
||||||
{selectedTemplate && selectedTemplateId !== "new" ? (
|
>
|
||||||
<Button
|
{isDeleting ? "Removendo..." : "Remover template"}
|
||||||
type="button"
|
</Button>
|
||||||
size="sm"
|
) : (
|
||||||
variant="outline"
|
<span className="text-xs text-neutral-500">Após salvar, o template ficará visível na lista.</span>
|
||||||
onClick={handleDelete}
|
)}
|
||||||
disabled={isDeleting}
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
>
|
<Button type="button" variant="outline" onClick={() => handleTemplateDialogOpenChange(false)} disabled={isSaving || isDeleting}>
|
||||||
{isDeleting ? "Removendo..." : "Remover"}
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
<Button type="button" onClick={handleSave} disabled={isSaving || isDeleting}>
|
||||||
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}>
|
{isSaving ? "Salvando..." : editingTemplate ? "Salvar mudanças" : "Criar template"}
|
||||||
{isSaving ? "Salvando..." : "Salvar template"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</CardContent>
|
</DialogContent>
|
||||||
</Card>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(/ /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>
|
||||||
|
|
|
||||||
|
|
@ -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,55 +980,85 @@ 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>
|
||||||
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
<div className="space-y-2">
|
||||||
<PopoverTrigger asChild>
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px]">
|
||||||
<button
|
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
||||||
type="button"
|
<PopoverTrigger asChild>
|
||||||
className={cn(
|
<button
|
||||||
"flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-left text-sm text-neutral-800 shadow-sm hover:bg-slate-50",
|
type="button"
|
||||||
!visitDate && "text-muted-foreground"
|
className={cn(
|
||||||
)}
|
"flex h-8 w-full items-center justify-between rounded-full border border-slate-300 bg-white px-3 text-left text-sm text-neutral-800 shadow-sm hover:bg-slate-50",
|
||||||
>
|
!visitDate && "text-muted-foreground"
|
||||||
{visitDate
|
)}
|
||||||
? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
|
>
|
||||||
: "Selecione a data"}
|
{visitDate
|
||||||
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
? format(visitDate, "dd/MM/yyyy", { locale: ptBR })
|
||||||
</button>
|
: "Selecione a data"}
|
||||||
</PopoverTrigger>
|
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
</button>
|
||||||
<Calendar
|
</PopoverTrigger>
|
||||||
mode="single"
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
selected={visitDate ?? undefined}
|
<Calendar
|
||||||
onSelect={(date) => {
|
mode="single"
|
||||||
if (!date) {
|
selected={visitDate ?? undefined}
|
||||||
form.setValue("visitDate", null, {
|
onSelect={(date) => {
|
||||||
|
if (!date) {
|
||||||
|
form.setValue("visitDate", null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: form.formState.isSubmitted,
|
||||||
|
})
|
||||||
|
form.setValue("visitTime", null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: form.formState.isSubmitted,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const iso = date.toISOString().slice(0, 10)
|
||||||
|
form.setValue("visitDate", iso, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
shouldValidate: form.formState.isSubmitted,
|
||||||
|
})
|
||||||
|
setVisitDatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<TimePicker
|
||||||
|
value={typeof visitTimeValue === "string" ? visitTimeValue : ""}
|
||||||
|
onChange={(value) =>
|
||||||
|
form.setValue("visitTime", value || null, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldTouch: true,
|
shouldTouch: true,
|
||||||
shouldValidate: form.formState.isSubmitted,
|
shouldValidate: form.formState.isSubmitted,
|
||||||
})
|
})
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const iso = date.toISOString().slice(0, 10)
|
stepMinutes={5}
|
||||||
form.setValue("visitDate", iso, {
|
className="h-8 rounded-full border border-slate-300 bg-white text-sm font-medium text-neutral-800 shadow-sm"
|
||||||
shouldDirty: true,
|
/>
|
||||||
shouldTouch: true,
|
<FieldError
|
||||||
shouldValidate: form.formState.isSubmitted,
|
errors={
|
||||||
})
|
form.formState.errors.visitTime
|
||||||
setVisitDatePickerOpen(false)
|
? [{ message: form.formState.errors.visitTime.message as string }]
|
||||||
}}
|
: []
|
||||||
initialFocus
|
}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</div>
|
||||||
</Popover>
|
</div>
|
||||||
<FieldError
|
<FieldError
|
||||||
errors={
|
errors={
|
||||||
form.formState.errors.visitDate
|
form.formState.errors.visitDate
|
||||||
? [{ message: form.formState.errors.visitDate.message as string }]
|
? [{ message: form.formState.errors.visitDate.message as string }]
|
||||||
: []
|
: []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : null}
|
||||||
<Field>
|
<Field>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue