Fix Excel export XML order and gate time adjustments on close

This commit is contained in:
codex-bot 2025-10-31 14:47:37 -03:00
parent be9816a3a8
commit 9d569d987d
4 changed files with 379 additions and 254 deletions

View file

@ -59,6 +59,8 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
import { TicketStatusBadge } from "@/components/tickets/status-badge"
import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket"
type MachineMetrics = Record<string, unknown> | null
@ -92,8 +94,8 @@ type MachineTicketSummary = {
id: string
reference: number
subject: string
status: string
priority: string
status: TicketStatus
priority: TicketPriority
updatedAt: number
createdAt: number
machine: { id: string | null; hostname: string | null } | null
@ -870,11 +872,24 @@ const statusLabels: Record<string, string> = {
unknown: "Desconhecida",
}
const TICKET_STATUS_LABELS: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
const TICKET_PRIORITY_META: Record<string, { label: string; badgeClass: string }> = {
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-600" },
MEDIUM: { label: "Média", badgeClass: "border border-sky-200 bg-sky-100 text-sky-700" },
HIGH: { label: "Alta", badgeClass: "border border-amber-200 bg-amber-50 text-amber-700" },
URGENT: { label: "Urgente", badgeClass: "border border-rose-200 bg-rose-50 text-rose-700" },
}
function getTicketPriorityMeta(priority: TicketPriority | string | null | undefined) {
if (!priority) {
return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" }
}
const normalized = priority.toUpperCase()
return (
TICKET_PRIORITY_META[normalized] ?? {
label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(),
badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600",
}
)
}
const statusClasses: Record<string, string> = {
@ -2347,10 +2362,13 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
<p className="text-xs text-[color:var(--accent-foreground)]/80">Nenhum chamado em aberto registrado diretamente por esta máquina.</p>
) : (
<ul className="space-y-2">
{machineTickets.map((ticket) => (
<li
key={ticket.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm"
{machineTickets.map((ticket) => {
const priorityMeta = getTicketPriorityMeta(ticket.priority)
return (
<li key={ticket.id}>
<Link
href={`/tickets/${ticket.id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[color:var(--accent)] bg-white px-3 py-2 text-sm shadow-sm transition hover:-translate-y-0.5 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)] focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-neutral-900">
@ -2361,15 +2379,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
{ticket.priority}
</Badge>
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
{TICKET_STATUS_LABELS[ticket.status] ?? ticket.status}
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
{priorityMeta.label}
</Badge>
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
</div>
</Link>
</li>
))}
)
})}
</ul>
)}
</div>

View file

@ -9,6 +9,10 @@ import { Button } from "@/components/ui/button"
import { Spinner } from "@/components/ui/spinner"
import { RichTextEditor, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor"
import { toast } from "sonner"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
type ClosingTemplate = { id: string; title: string; body: string }
@ -17,6 +21,23 @@ const DEFAULT_COMPANY_NAME = "Rever Tecnologia"
const sanitizeTemplate = (html: string) => stripLeadingEmptyParagraphs(sanitizeEditorHtml(html.trim()))
export type AdjustWorkSummaryResult = {
ticketId: Id<"tickets">
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number
perAgentTotals?: Array<{
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}>
}
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
{
id: "default-standard",
@ -63,6 +84,21 @@ function applyTemplatePlaceholders(html: string, customerName?: string | null, a
.replace(/{{\s*(empresa|company|companhia)\s*}}/gi, DEFAULT_COMPANY_NAME)
}
const splitDuration = (ms: number) => {
const safeMs = Number.isFinite(ms) && ms > 0 ? ms : 0
const totalMinutes = Math.round(safeMs / 60000)
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
return { hours, minutes }
}
const formatDurationLabel = (ms: number) => {
const { hours, minutes } = splitDuration(ms)
if (hours > 0 && minutes > 0) return `${hours}h ${minutes}min`
if (hours > 0) return `${hours}h`
return `${minutes}min`
}
export function CloseTicketDialog({
open,
onOpenChange,
@ -72,6 +108,9 @@ export function CloseTicketDialog({
requesterName,
agentName,
onSuccess,
workSummary,
onWorkSummaryAdjusted,
canAdjustTime = false,
}: {
open: boolean
onOpenChange: (open: boolean) => void
@ -81,9 +120,17 @@ export function CloseTicketDialog({
requesterName?: string | null
agentName?: string | null
onSuccess: () => void
workSummary?: {
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
} | null
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
canAdjustTime?: boolean
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const addComment = useMutation(api.tickets.addComment)
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
const closingTemplates = useQuery(
actorId && open ? api.commentTemplates.list : "skip",
@ -101,6 +148,13 @@ export function CloseTicketDialog({
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
const [message, setMessage] = useState<string>("")
const [isSubmitting, setIsSubmitting] = useState(false)
const [shouldAdjustTime, setShouldAdjustTime] = useState<boolean>(false)
const [internalHours, setInternalHours] = useState<string>("0")
const [internalMinutes, setInternalMinutes] = useState<string>("0")
const [externalHours, setExternalHours] = useState<string>("0")
const [externalMinutes, setExternalMinutes] = useState<string>("0")
const [adjustReason, setAdjustReason] = useState<string>("")
const enableAdjustment = Boolean(canAdjustTime && workSummary)
const hydrateTemplateBody = useCallback((templateHtml: string) => {
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
@ -108,12 +162,20 @@ export function CloseTicketDialog({
}, [requesterName, agentName])
useEffect(() => {
if (!open) {
if (open) return
setSelectedTemplateId(null)
setMessage("")
setIsSubmitting(false)
return
}
setShouldAdjustTime(false)
setAdjustReason("")
setInternalHours("0")
setInternalMinutes("0")
setExternalHours("0")
setExternalMinutes("0")
}, [open])
useEffect(() => {
if (!open) return
if (templates.length > 0 && !selectedTemplateId && !message) {
const first = templates[0]
const hydrated = hydrateTemplateBody(first.body)
@ -122,6 +184,28 @@ export function CloseTicketDialog({
}
}, [open, templates, selectedTemplateId, message, hydrateTemplateBody])
useEffect(() => {
if (!open || !enableAdjustment || !shouldAdjustTime) return
const internal = splitDuration(workSummary?.internalWorkedMs ?? 0)
const external = splitDuration(workSummary?.externalWorkedMs ?? 0)
setInternalHours(internal.hours.toString())
setInternalMinutes(internal.minutes.toString())
setExternalHours(external.hours.toString())
setExternalMinutes(external.minutes.toString())
}, [
open,
enableAdjustment,
shouldAdjustTime,
workSummary?.internalWorkedMs,
workSummary?.externalWorkedMs,
])
useEffect(() => {
if (!shouldAdjustTime) {
setAdjustReason("")
}
}, [shouldAdjustTime])
const handleTemplateSelect = (template: ClosingTemplate) => {
setSelectedTemplateId(template.id)
setMessage(hydrateTemplateBody(template.body))
@ -132,9 +216,65 @@ export function CloseTicketDialog({
toast.error("É necessário estar autenticado para encerrar o ticket.")
return
}
const applyAdjustment = enableAdjustment && shouldAdjustTime
let targetInternalMs = 0
let targetExternalMs = 0
let trimmedReason = ""
if (applyAdjustment) {
const parsePart = (value: string, label: string) => {
const trimmed = value.trim()
if (trimmed.length === 0) return 0
if (!/^\d+$/u.test(trimmed)) {
toast.error(`Informe um número válido para ${label}.`)
return null
}
return Number.parseInt(trimmed, 10)
}
const internalHoursValue = parsePart(internalHours, "horas internas")
if (internalHoursValue === null) return
const internalMinutesValue = parsePart(internalMinutes, "minutos internos")
if (internalMinutesValue === null) return
if (internalMinutesValue >= 60) {
toast.error("Os minutos internos devem estar entre 0 e 59.")
return
}
const externalHoursValue = parsePart(externalHours, "horas externas")
if (externalHoursValue === null) return
const externalMinutesValue = parsePart(externalMinutes, "minutos externos")
if (externalMinutesValue === null) return
if (externalMinutesValue >= 60) {
toast.error("Os minutos externos devem estar entre 0 e 59.")
return
}
targetInternalMs = (internalHoursValue * 60 + internalMinutesValue) * 60000
targetExternalMs = (externalHoursValue * 60 + externalMinutesValue) * 60000
trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
}
toast.dismiss("close-ticket")
setIsSubmitting(true)
toast.loading("Encerrando ticket...", { id: "close-ticket" })
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
try {
if (applyAdjustment) {
const result = (await adjustWorkSummary({
ticketId: ticketId as unknown as Id<"tickets">,
actorId,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as AdjustWorkSummaryResult
onWorkSummaryAdjusted?.(result)
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
@ -153,7 +293,9 @@ export function CloseTicketDialog({
onSuccess()
} catch (error) {
console.error(error)
toast.error("Não foi possível encerrar o ticket.", { id: "close-ticket" })
toast.error(applyAdjustment ? "Não foi possível ajustar o tempo ou encerrar o ticket." : "Não foi possível encerrar o ticket.", {
id: "close-ticket",
})
} finally {
setIsSubmitting(false)
}
@ -205,6 +347,129 @@ export function CloseTicketDialog({
<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.</p>
</div>
</div>
{enableAdjustment ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-neutral-800">Ajustar tempo antes de encerrar</p>
<p className="text-xs text-neutral-500">
Atualize o tempo registrado e informe um motivo para o ajuste. O comentário fica visível apenas para a equipe.
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="toggle-time-adjustment"
checked={shouldAdjustTime}
onCheckedChange={(checked) => setShouldAdjustTime(Boolean(checked))}
disabled={isSubmitting}
/>
<Label htmlFor="toggle-time-adjustment" className="text-sm font-medium text-neutral-800">
Incluir ajuste
</Label>
</div>
</div>
{shouldAdjustTime ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo interno</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-internal-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-internal-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={internalHours}
onChange={(event) => setInternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-internal-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-internal-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={internalMinutes}
onChange={(event) => setInternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.internalWorkedMs ?? 0)}
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Tempo externo</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label htmlFor="adjust-external-hours" className="text-xs text-neutral-600">
Horas
</Label>
<Input
id="adjust-external-hours"
type="number"
min={0}
step={1}
inputMode="numeric"
value={externalHours}
onChange={(event) => setExternalHours(event.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1">
<Label htmlFor="adjust-external-minutes" className="text-xs text-neutral-600">
Minutos
</Label>
<Input
id="adjust-external-minutes"
type="number"
min={0}
max={59}
step={1}
inputMode="numeric"
value={externalMinutes}
onChange={(event) => setExternalMinutes(event.target.value)}
disabled={isSubmitting}
/>
</div>
</div>
<p className="text-xs text-neutral-500">
Atual: {formatDurationLabel(workSummary?.externalWorkedMs ?? 0)}
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason" className="text-xs text-neutral-600">
Motivo do ajuste
</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={3}
disabled={isSubmitting}
/>
<p className="text-xs text-neutral-500">
Registre o motivo para fins de auditoria interna. Informe valores em minutos quando menor que 1 hora.
</p>
</div>
</div>
) : null}
</div>
) : null}
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
<div className="text-xs text-neutral-500">
O comentário será público e ficará registrado no histórico do ticket.

View file

@ -1,9 +1,9 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil, IconAdjustmentsHorizontal } from "@tabler/icons-react"
import { IconClock, IconDownload, IconPlayerPause, IconPlayerPlay, IconPencil } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -16,12 +16,11 @@ import { Separator } from "@/components/ui/separator"
import { PrioritySelect } from "@/components/tickets/priority-select"
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
import { StatusSelect } from "@/components/tickets/status-select"
import { CloseTicketDialog } from "@/components/tickets/close-ticket-dialog"
import { CloseTicketDialog, type AdjustWorkSummaryResult } from "@/components/tickets/close-ticket-dialog"
import { CheckCircle2 } 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"
import { Label } from "@/components/ui/label"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
@ -165,7 +164,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const updateSummary = useMutation(api.tickets.updateSummary)
const startWork = useMutation(api.tickets.startWork)
const pauseWork = useMutation(api.tickets.pauseWork)
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
const updateCategories = useMutation(api.tickets.updateCategories)
const agents = (useQuery(api.users.listAgents, { tenantId: ticket.tenantId }) as Doc<"users">[] | undefined) ?? []
const queuesEnabled = Boolean(isStaff && convexUserId)
@ -251,11 +249,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const [pausing, setPausing] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const [closeOpen, setCloseOpen] = useState(false)
const [adjustDialogOpen, setAdjustDialogOpen] = useState(false)
const [adjustInternalHours, setAdjustInternalHours] = useState("")
const [adjustExternalHours, setAdjustExternalHours] = useState("")
const [adjustReason, setAdjustReason] = useState("")
const [adjusting, setAdjusting] = useState(false)
const [assigneeChangeReason, setAssigneeChangeReason] = useState("")
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
@ -652,25 +645,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
])
const [workSummary, setWorkSummary] = useState<WorkSummarySnapshot | null>(initialWorkSummary)
const formatHoursInput = useCallback((ms: number) => {
if (!Number.isFinite(ms) || ms <= 0) {
return "0"
}
const hours = ms / 3600000
const rounded = Math.round(hours * 100) / 100
return rounded.toString()
}, [])
const effectiveWorkSummary = workSummary ?? initialWorkSummary
useEffect(() => {
if (!adjustDialogOpen) return
const internalMs = effectiveWorkSummary?.internalWorkedMs ?? 0
const externalMs = effectiveWorkSummary?.externalWorkedMs ?? 0
setAdjustInternalHours(formatHoursInput(internalMs))
setAdjustExternalHours(formatHoursInput(externalMs))
setAdjustReason("")
}, [adjustDialogOpen, effectiveWorkSummary, formatHoursInput])
const serverOffsetRef = useRef<number>(0)
const calibrateServerOffset = useCallback(
@ -971,68 +946,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
}
}
const handleAdjustSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!convexUserId) {
toast.error("Sessão expirada. Faça login novamente.")
return
}
const parseHours = (value: string) => {
const normalized = value.replace(",", ".").trim()
if (normalized.length === 0) return 0
const numeric = Number.parseFloat(normalized)
if (!Number.isFinite(numeric) || numeric < 0) return null
return numeric
}
const internalHoursParsed = parseHours(adjustInternalHours)
if (internalHoursParsed === null) {
toast.error("Informe um valor válido para horas internas.")
return
}
const externalHoursParsed = parseHours(adjustExternalHours)
if (externalHoursParsed === null) {
toast.error("Informe um valor válido para horas externas.")
return
}
const trimmedReason = adjustReason.trim()
if (trimmedReason.length < 5) {
toast.error("Descreva o motivo do ajuste (mínimo de 5 caracteres).")
return
}
toast.dismiss("adjust-hours")
toast.loading("Ajustando horas...", { id: "adjust-hours" })
setAdjusting(true)
try {
const targetInternalMs = Math.round(internalHoursParsed * 3600000)
const targetExternalMs = Math.round(externalHoursParsed * 3600000)
const result = (await adjustWorkSummaryMutation({
ticketId: ticket.id as Id<"tickets">,
actorId: convexUserId as Id<"users">,
internalWorkedMs: targetInternalMs,
externalWorkedMs: targetExternalMs,
reason: trimmedReason,
})) as {
ticketId: Id<"tickets">
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
serverNow?: number
perAgentTotals?: Array<{
agentId: string
agentName: string | null
agentEmail: string | null
avatarUrl: string | null
totalWorkedMs: number
internalWorkedMs: number
externalWorkedMs: number
}>
}
const handleWorkSummaryAdjusted = useCallback(
(result: AdjustWorkSummaryResult) => {
calibrateServerOffset(result?.serverNow ?? null)
setWorkSummary((prev) => {
const base: WorkSummarySnapshot =
prev ??
({
const fallback: WorkSummarySnapshot = {
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: 0,
internalWorkedMs: 0,
@ -1040,7 +958,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
serverNow: result?.serverNow ?? null,
activeSession: null,
perAgentTotals: [],
} satisfies WorkSummarySnapshot)
}
const base = prev ?? fallback
return {
...base,
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
@ -1060,25 +979,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
: base.perAgentTotals,
}
})
toast.success("Horas ajustadas com sucesso.", { id: "adjust-hours" })
setAdjustDialogOpen(false)
setAdjustReason("")
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível ajustar as horas."
toast.error(message, { id: "adjust-hours" })
} finally {
setAdjusting(false)
}
},
[
adjustInternalHours,
adjustExternalHours,
adjustReason,
adjustWorkSummaryMutation,
calibrateServerOffset,
convexUserId,
ticket.id,
],
[calibrateServerOffset, ticket.id],
)
const handleExportPdf = useCallback(async () => {
@ -1136,17 +1038,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</TooltipContent>
</Tooltip>
) : null}
{canAdjustWork && workSummary ? (
<Button
type="button"
variant="outline"
size="sm"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-700 hover:bg-slate-50"
onClick={() => setAdjustDialogOpen(true)}
>
<IconAdjustmentsHorizontal className="size-4" /> Ajustar horas
</Button>
) : null}
{!editing ? (
<Button
size="icon"
@ -1179,73 +1070,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
actorId={convexUserId as Id<"users"> | null}
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
agentName={agentName}
workSummary={
effectiveWorkSummary
? {
totalWorkedMs: effectiveWorkSummary.totalWorkedMs,
internalWorkedMs: effectiveWorkSummary.internalWorkedMs,
externalWorkedMs: effectiveWorkSummary.externalWorkedMs,
}
: null
}
canAdjustTime={canAdjustWork && Boolean(effectiveWorkSummary)}
onWorkSummaryAdjusted={handleWorkSummaryAdjusted}
onSuccess={() => setStatus("RESOLVED")}
/>
<Dialog open={adjustDialogOpen} onOpenChange={setAdjustDialogOpen}>
<DialogContent className="max-w-lg">
<form onSubmit={handleAdjustSubmit} className="space-y-6">
<DialogHeader>
<DialogTitle>Ajustar horas do chamado</DialogTitle>
<DialogDescription>
Atualize os tempos registrados e descreva o motivo do ajuste. Apenas agentes e administradores visualizam este log.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="adjust-internal">Horas internas</Label>
<Input
id="adjust-internal"
type="number"
min={0}
step={0.25}
value={adjustInternalHours}
onChange={(event) => setAdjustInternalHours(event.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-external">Horas externas</Label>
<Input
id="adjust-external"
type="number"
min={0}
step={0.25}
value={adjustExternalHours}
onChange={(event) => setAdjustExternalHours(event.target.value)}
required
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="adjust-reason">Motivo do ajuste</Label>
<Textarea
id="adjust-reason"
value={adjustReason}
onChange={(event) => setAdjustReason(event.target.value)}
placeholder="Descreva por que o tempo precisa ser ajustado..."
rows={4}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdjustDialogOpen(false)
setAdjusting(false)
}}
disabled={adjusting}
>
Cancelar
</Button>
<Button type="submit" disabled={adjusting}>
{adjusting ? "Salvando..." : "Salvar ajuste"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">

View file

@ -27,10 +27,8 @@ function escapeXml(value: string): string {
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\u0008/g, "")
.replace(/\u000B/g, "")
.replace(/\u000C/g, "")
.replace(/\u0000/g, "")
// remove invalid control characters (XML 1.0)
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")
}
function columnRef(index: number): string {
@ -60,7 +58,7 @@ function formatCell(value: unknown, colIndex: number, rowNumber: number, styleIn
}
if (typeof value === "boolean") {
return `<c r="${ref}"${styleAttr}><v>${value ? 1 : 0}</v></c>`
return `<c r="${ref}"${styleAttr} t="b"><v>${value ? 1 : 0}</v></c>`
}
let text: string
@ -123,17 +121,24 @@ function buildWorksheetXml(config: WorksheetConfig, styles: WorksheetStyles): st
sheetViews = `<sheetViews><sheetView workbookViewId="0">${pane}</sheetView></sheetViews>`
}
const usedRangeColumn = config.headers.length > 0 ? columnRef(config.headers.length - 1) : "A"
const dimensionXml =
config.headers.length > 0 && totalRows > 0
? `<dimension ref="A1:${usedRangeColumn}${totalRows}"/>`
: ""
const autoFilter =
config.autoFilter && config.headers.length > 0 && totalRows > 1
? `<autoFilter ref="A1:${columnRef(config.headers.length - 1)}${totalRows}"/>`
? `<autoFilter ref="A1:${usedRangeColumn}${totalRows}"/>`
: ""
return [
XML_DECLARATION,
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
dimensionXml,
sheetViews,
colsXml,
' <sheetFormatPr defaultRowHeight="15"/>',
colsXml,
" <sheetData>",
rows.map((row) => ` ${row}`).join("\n"),
" </sheetData>",