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
|
|
@ -32,6 +32,12 @@ const DEFAULT_PHONE_NUMBER = "(11) 4173-5368"
|
|||
const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
|
||||
|
||||
const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim()))
|
||||
const htmlToPlainText = (value: string) =>
|
||||
value
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
|
||||
export type AdjustWorkSummaryResult = {
|
||||
ticketId: Id<"tickets">
|
||||
|
|
@ -205,6 +211,9 @@ export function CloseTicketDialog({
|
|||
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
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 [shouldAdjustTime, setShouldAdjustTime] = useState<boolean>(false)
|
||||
const [internalHours, setInternalHours] = useState<string>("0")
|
||||
|
|
@ -228,6 +237,12 @@ export function CloseTicketDialog({
|
|||
|
||||
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 normalizedReference = useMemo(() => {
|
||||
|
|
@ -540,7 +555,14 @@ export function CloseTicketDialog({
|
|||
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 isLastStep = currentStep === WIZARD_STEPS.length - 1
|
||||
|
||||
|
|
@ -930,16 +952,27 @@ export function CloseTicketDialog({
|
|||
</Button>
|
||||
</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>
|
||||
<RichTextEditor
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
minHeight={220}
|
||||
placeholder="Escreva uma mensagem final para o cliente..."
|
||||
placeholder="Descreva o encerramento para o cliente..."
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
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.
|
||||
<p
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { priorityStyles } from "@/lib/ticket-priority-style"
|
|||
import { normalizeCustomFieldInputs } from "@/lib/ticket-form-helpers"
|
||||
import type { TicketFormDefinition, TicketFormFieldDefinition } from "@/lib/ticket-form-types"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { TimePicker } from "@/components/ui/time-picker"
|
||||
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||
|
||||
type TriggerVariant = "button" | "card"
|
||||
|
|
@ -119,6 +120,7 @@ const schema = z.object({
|
|||
channel: z.enum(["EMAIL", "WHATSAPP", "CHAT", "PHONE", "API", "MANUAL"]).default("MANUAL"),
|
||||
queueName: z.string().nullable().optional(),
|
||||
visitDate: z.string().nullable().optional(),
|
||||
visitTime: z.string().nullable().optional(),
|
||||
assigneeId: z.string().nullable().optional(),
|
||||
companyId: z.string().optional(),
|
||||
requesterId: z.string().min(1, "Selecione um solicitante"),
|
||||
|
|
@ -144,6 +146,8 @@ export function NewTicketDialog({
|
|||
priority: "MEDIUM",
|
||||
channel: "MANUAL",
|
||||
queueName: null,
|
||||
visitDate: null,
|
||||
visitTime: null,
|
||||
assigneeId: null,
|
||||
companyId: NO_COMPANY_VALUE,
|
||||
requesterId: "",
|
||||
|
|
@ -286,6 +290,7 @@ export function NewTicketDialog({
|
|||
const priorityValue = form.watch("priority") as TicketPriority
|
||||
const queueValue = form.watch("queueName") ?? "NONE"
|
||||
const visitDateValue = form.watch("visitDate") ?? null
|
||||
const visitTimeValue = form.watch("visitTime") ?? null
|
||||
const assigneeValue = form.watch("assigneeId") ?? null
|
||||
const assigneeSelectValue = assigneeValue ?? "NONE"
|
||||
const requesterValue = form.watch("requesterId") ?? ""
|
||||
|
|
@ -301,12 +306,21 @@ export function NewTicketDialog({
|
|||
)
|
||||
const visitDate = useMemo(() => {
|
||||
if (!visitDateValue) return null
|
||||
const timeSegment =
|
||||
typeof visitTimeValue === "string" && visitTimeValue.length > 0 ? visitTimeValue : "00:00"
|
||||
const normalizedTime = timeSegment.length === 5 ? `${timeSegment}:00` : timeSegment
|
||||
try {
|
||||
return parseISO(visitDateValue)
|
||||
return parseISO(`${visitDateValue}T${normalizedTime}`)
|
||||
} catch {
|
||||
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 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." })
|
||||
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 }> = []
|
||||
|
|
@ -551,9 +569,10 @@ export function NewTicketDialog({
|
|||
const selectedAssignee = form.getValues("assigneeId") ?? null
|
||||
const requesterToSend = values.requesterId as Id<"users">
|
||||
let visitDateTimestamp: number | undefined
|
||||
if (isVisitQueueOnSubmit && values.visitDate) {
|
||||
if (isVisitQueueOnSubmit && values.visitDate && values.visitTime) {
|
||||
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()
|
||||
} catch {
|
||||
visitDateTimestamp = undefined
|
||||
|
|
@ -604,6 +623,7 @@ export function NewTicketDialog({
|
|||
categoryId: "",
|
||||
subcategoryId: "",
|
||||
visitDate: null,
|
||||
visitTime: null,
|
||||
})
|
||||
form.clearErrors()
|
||||
setSelectedFormKey("default")
|
||||
|
|
@ -960,55 +980,85 @@ export function NewTicketDialog({
|
|||
{isVisitQueue ? (
|
||||
<Field>
|
||||
<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>
|
||||
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={visitDate ?? undefined}
|
||||
onSelect={(date) => {
|
||||
if (!date) {
|
||||
form.setValue("visitDate", null, {
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_140px]">
|
||||
<Popover open={visitDatePickerOpen} onOpenChange={setVisitDatePickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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"}
|
||||
<CalendarIcon className="ml-2 size-4 text-neutral-500" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={visitDate ?? undefined}
|
||||
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,
|
||||
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>
|
||||
<FieldError
|
||||
errors={
|
||||
form.formState.errors.visitDate
|
||||
? [{ message: form.formState.errors.visitDate.message as string }]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
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
|
||||
errors={
|
||||
form.formState.errors.visitDate
|
||||
? [{ message: form.formState.errors.visitDate.message as string }]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link"
|
||||
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 { IconClock, IconDownload, IconLink, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-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 { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
|
||||
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 { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
|
@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
|
|||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { useTicketCategories } from "@/hooks/use-ticket-categories"
|
||||
import { useDefaultQueues } from "@/hooks/use-default-queues"
|
||||
import { VISIT_KEYWORDS } from "@/lib/ticket-matchers"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -42,6 +43,9 @@ import {
|
|||
type SessionStartOrigin,
|
||||
} from "./ticket-timer.utils"
|
||||
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 {
|
||||
ticket: TicketWithDetails
|
||||
|
|
@ -197,7 +201,32 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||
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 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 companiesRemote = useQuery(
|
||||
api.companies.list,
|
||||
|
|
@ -284,6 +313,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
const [requesterSelection, setRequesterSelection] = useState<string | null>(ticket.requester.id)
|
||||
const [requesterError, setRequesterError] = useState<string | null>(null)
|
||||
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 selectedSubcategoryId = categorySelection.subcategoryId
|
||||
const dirty = useMemo(() => subject !== ticket.subject, [subject, ticket.subject])
|
||||
|
|
@ -386,10 +423,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
}, [companySelection, customers])
|
||||
const assigneeDirty = useMemo(() => assigneeSelection !== currentAssigneeId, [assigneeSelection, currentAssigneeId])
|
||||
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 assigneeReasonValid = normalizedAssigneeReason.length === 0 || normalizedAssigneeReason.length >= 5
|
||||
const saveDisabled = !formDirty || saving || !assigneeReasonValid
|
||||
const saveDisabled = !formDirty || saving || !assigneeReasonValid || visitHasInvalid
|
||||
const companyLabel = useMemo(() => {
|
||||
if (ticket.company?.name) return ticket.company.name
|
||||
if (isAvulso) return "Cliente avulso"
|
||||
|
|
@ -520,6 +559,39 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
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 (!requesterSelection) {
|
||||
setRequesterError("Selecione um solicitante.")
|
||||
|
|
@ -634,6 +706,9 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
setAssigneeSelection(currentAssigneeId)
|
||||
setAssigneeChangeReason("")
|
||||
setAssigneeReasonError(null)
|
||||
setVisitDateInput(initialVisitDateValue)
|
||||
setVisitTimeInput(initialVisitTimeValue)
|
||||
setVisitError(null)
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
|
|
@ -651,6 +726,25 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
setCompanySelection(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(() => {
|
||||
if (!editing) return
|
||||
if (!selectedCategoryId) {
|
||||
|
|
@ -1548,7 +1642,64 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
|||
<span className={subtleBadgeClass}>{updatedRelative}</span>
|
||||
</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">
|
||||
<span className={sectionLabelClass}>SLA até</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