Fix Excel export XML order and gate time adjustments on close
This commit is contained in:
parent
be9816a3a8
commit
9d569d987d
4 changed files with 379 additions and 254 deletions
|
|
@ -59,6 +59,8 @@ import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useAuth } from "@/lib/auth-client"
|
import { useAuth } from "@/lib/auth-client"
|
||||||
import type { Id } from "@/convex/_generated/dataModel"
|
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
|
type MachineMetrics = Record<string, unknown> | null
|
||||||
|
|
||||||
|
|
@ -92,8 +94,8 @@ type MachineTicketSummary = {
|
||||||
id: string
|
id: string
|
||||||
reference: number
|
reference: number
|
||||||
subject: string
|
subject: string
|
||||||
status: string
|
status: TicketStatus
|
||||||
priority: string
|
priority: TicketPriority
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
machine: { id: string | null; hostname: string | null } | null
|
machine: { id: string | null; hostname: string | null } | null
|
||||||
|
|
@ -870,11 +872,24 @@ const statusLabels: Record<string, string> = {
|
||||||
unknown: "Desconhecida",
|
unknown: "Desconhecida",
|
||||||
}
|
}
|
||||||
|
|
||||||
const TICKET_STATUS_LABELS: Record<string, string> = {
|
const TICKET_PRIORITY_META: Record<string, { label: string; badgeClass: string }> = {
|
||||||
PENDING: "Pendente",
|
LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-600" },
|
||||||
AWAITING_ATTENDANCE: "Em andamento",
|
MEDIUM: { label: "Média", badgeClass: "border border-sky-200 bg-sky-100 text-sky-700" },
|
||||||
PAUSED: "Pausado",
|
HIGH: { label: "Alta", badgeClass: "border border-amber-200 bg-amber-50 text-amber-700" },
|
||||||
RESOLVED: "Resolvido",
|
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> = {
|
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>
|
<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">
|
<ul className="space-y-2">
|
||||||
{machineTickets.map((ticket) => (
|
{machineTickets.map((ticket) => {
|
||||||
<li
|
const priorityMeta = getTicketPriorityMeta(ticket.priority)
|
||||||
key={ticket.id}
|
return (
|
||||||
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"
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate font-medium text-neutral-900">
|
<p className="truncate font-medium text-neutral-900">
|
||||||
|
|
@ -2361,15 +2379,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="border-slate-200 text-[11px] uppercase text-neutral-600">
|
<Badge className={cn("rounded-full px-3 py-1 text-xs font-semibold", priorityMeta.badgeClass)}>
|
||||||
{ticket.priority}
|
{priorityMeta.label}
|
||||||
</Badge>
|
|
||||||
<Badge className="bg-indigo-600 text-[11px] uppercase tracking-wide text-white">
|
|
||||||
{TICKET_STATUS_LABELS[ticket.status] ?? ticket.status}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<TicketStatusBadge status={ticket.status} className="h-7 px-3 text-xs font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import { Button } from "@/components/ui/button"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
import { RichTextEditor, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor"
|
import { RichTextEditor, sanitizeEditorHtml, stripLeadingEmptyParagraphs } from "@/components/ui/rich-text-editor"
|
||||||
import { toast } from "sonner"
|
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 }
|
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()))
|
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[] = [
|
const DEFAULT_CLOSING_TEMPLATES: ClosingTemplate[] = [
|
||||||
{
|
{
|
||||||
id: "default-standard",
|
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)
|
.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({
|
export function CloseTicketDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -72,6 +108,9 @@ export function CloseTicketDialog({
|
||||||
requesterName,
|
requesterName,
|
||||||
agentName,
|
agentName,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
workSummary,
|
||||||
|
onWorkSummaryAdjusted,
|
||||||
|
canAdjustTime = false,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
|
@ -81,9 +120,17 @@ export function CloseTicketDialog({
|
||||||
requesterName?: string | null
|
requesterName?: string | null
|
||||||
agentName?: string | null
|
agentName?: string | null
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
|
workSummary?: {
|
||||||
|
totalWorkedMs: number
|
||||||
|
internalWorkedMs: number
|
||||||
|
externalWorkedMs: number
|
||||||
|
} | null
|
||||||
|
onWorkSummaryAdjusted?: (result: AdjustWorkSummaryResult) => void
|
||||||
|
canAdjustTime?: boolean
|
||||||
}) {
|
}) {
|
||||||
const updateStatus = useMutation(api.tickets.updateStatus)
|
const updateStatus = useMutation(api.tickets.updateStatus)
|
||||||
const addComment = useMutation(api.tickets.addComment)
|
const addComment = useMutation(api.tickets.addComment)
|
||||||
|
const adjustWorkSummary = useMutation(api.tickets.adjustWorkSummary)
|
||||||
|
|
||||||
const closingTemplates = useQuery(
|
const closingTemplates = useQuery(
|
||||||
actorId && open ? api.commentTemplates.list : "skip",
|
actorId && open ? api.commentTemplates.list : "skip",
|
||||||
|
|
@ -101,6 +148,13 @@ 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 [isSubmitting, setIsSubmitting] = useState(false)
|
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 hydrateTemplateBody = useCallback((templateHtml: string) => {
|
||||||
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
|
const withPlaceholders = applyTemplatePlaceholders(templateHtml, requesterName, agentName)
|
||||||
|
|
@ -108,12 +162,20 @@ export function CloseTicketDialog({
|
||||||
}, [requesterName, agentName])
|
}, [requesterName, agentName])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (open) return
|
||||||
setSelectedTemplateId(null)
|
setSelectedTemplateId(null)
|
||||||
setMessage("")
|
setMessage("")
|
||||||
setIsSubmitting(false)
|
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) {
|
if (templates.length > 0 && !selectedTemplateId && !message) {
|
||||||
const first = templates[0]
|
const first = templates[0]
|
||||||
const hydrated = hydrateTemplateBody(first.body)
|
const hydrated = hydrateTemplateBody(first.body)
|
||||||
|
|
@ -122,6 +184,28 @@ export function CloseTicketDialog({
|
||||||
}
|
}
|
||||||
}, [open, templates, selectedTemplateId, message, hydrateTemplateBody])
|
}, [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) => {
|
const handleTemplateSelect = (template: ClosingTemplate) => {
|
||||||
setSelectedTemplateId(template.id)
|
setSelectedTemplateId(template.id)
|
||||||
setMessage(hydrateTemplateBody(template.body))
|
setMessage(hydrateTemplateBody(template.body))
|
||||||
|
|
@ -132,9 +216,65 @@ export function CloseTicketDialog({
|
||||||
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
toast.error("É necessário estar autenticado para encerrar o ticket.")
|
||||||
return
|
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)
|
setIsSubmitting(true)
|
||||||
toast.loading("Encerrando ticket...", { id: "close-ticket" })
|
toast.loading(applyAdjustment ? "Ajustando tempo e encerrando ticket..." : "Encerrando ticket...", { id: "close-ticket" })
|
||||||
try {
|
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 })
|
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: "RESOLVED", actorId })
|
||||||
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
const withPlaceholders = applyTemplatePlaceholders(message, requesterName, agentName)
|
||||||
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
|
const sanitized = stripLeadingEmptyParagraphs(sanitizeEditorHtml(withPlaceholders))
|
||||||
|
|
@ -153,7 +293,9 @@ export function CloseTicketDialog({
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(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 {
|
} finally {
|
||||||
setIsSubmitting(false)
|
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>
|
<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>
|
||||||
</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">
|
<DialogFooter className="flex flex-wrap justify-between gap-3 pt-4">
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500">
|
||||||
O comentário será público e ficará registrado no histórico do ticket.
|
O comentário será público e ficará registrado no histórico do ticket.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
"use client"
|
"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 { format, formatDistanceToNow } from "date-fns"
|
||||||
import { ptBR } from "date-fns/locale"
|
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 { useMutation, useQuery } from "convex/react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { api } from "@/convex/_generated/api"
|
import { api } from "@/convex/_generated/api"
|
||||||
|
|
@ -16,12 +16,11 @@ import { Separator } from "@/components/ui/separator"
|
||||||
import { PrioritySelect } from "@/components/tickets/priority-select"
|
import { PrioritySelect } from "@/components/tickets/priority-select"
|
||||||
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
import { DeleteTicketDialog } from "@/components/tickets/delete-ticket-dialog"
|
||||||
import { StatusSelect } from "@/components/tickets/status-select"
|
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 { CheckCircle2 } 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"
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Spinner } from "@/components/ui/spinner"
|
import { Spinner } from "@/components/ui/spinner"
|
||||||
|
|
@ -165,7 +164,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const updateSummary = useMutation(api.tickets.updateSummary)
|
const updateSummary = useMutation(api.tickets.updateSummary)
|
||||||
const startWork = useMutation(api.tickets.startWork)
|
const startWork = useMutation(api.tickets.startWork)
|
||||||
const pauseWork = useMutation(api.tickets.pauseWork)
|
const pauseWork = useMutation(api.tickets.pauseWork)
|
||||||
const adjustWorkSummaryMutation = useMutation(api.tickets.adjustWorkSummary)
|
|
||||||
const updateCategories = useMutation(api.tickets.updateCategories)
|
const updateCategories = useMutation(api.tickets.updateCategories)
|
||||||
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 queuesEnabled = Boolean(isStaff && convexUserId)
|
const queuesEnabled = Boolean(isStaff && convexUserId)
|
||||||
|
|
@ -251,11 +249,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
const [pausing, setPausing] = useState(false)
|
const [pausing, setPausing] = useState(false)
|
||||||
const [exportingPdf, setExportingPdf] = useState(false)
|
const [exportingPdf, setExportingPdf] = useState(false)
|
||||||
const [closeOpen, setCloseOpen] = 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 [assigneeChangeReason, setAssigneeChangeReason] = useState("")
|
||||||
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
const [assigneeReasonError, setAssigneeReasonError] = useState<string | null>(null)
|
||||||
const [companySelection, setCompanySelection] = useState<string>(NO_COMPANY_VALUE)
|
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 [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
|
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 serverOffsetRef = useRef<number>(0)
|
||||||
|
|
||||||
const calibrateServerOffset = useCallback(
|
const calibrateServerOffset = useCallback(
|
||||||
|
|
@ -971,68 +946,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAdjustSubmit = useCallback(
|
const handleWorkSummaryAdjusted = useCallback(
|
||||||
async (event: FormEvent<HTMLFormElement>) => {
|
(result: AdjustWorkSummaryResult) => {
|
||||||
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
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
calibrateServerOffset(result?.serverNow ?? null)
|
calibrateServerOffset(result?.serverNow ?? null)
|
||||||
setWorkSummary((prev) => {
|
setWorkSummary((prev) => {
|
||||||
const base: WorkSummarySnapshot =
|
const fallback: WorkSummarySnapshot = {
|
||||||
prev ??
|
|
||||||
({
|
|
||||||
ticketId: ticket.id as Id<"tickets">,
|
ticketId: ticket.id as Id<"tickets">,
|
||||||
totalWorkedMs: 0,
|
totalWorkedMs: 0,
|
||||||
internalWorkedMs: 0,
|
internalWorkedMs: 0,
|
||||||
|
|
@ -1040,7 +958,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
serverNow: result?.serverNow ?? null,
|
serverNow: result?.serverNow ?? null,
|
||||||
activeSession: null,
|
activeSession: null,
|
||||||
perAgentTotals: [],
|
perAgentTotals: [],
|
||||||
} satisfies WorkSummarySnapshot)
|
}
|
||||||
|
const base = prev ?? fallback
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
|
totalWorkedMs: result?.totalWorkedMs ?? base.totalWorkedMs,
|
||||||
|
|
@ -1060,25 +979,8 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
: base.perAgentTotals,
|
: 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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[calibrateServerOffset, ticket.id],
|
||||||
adjustInternalHours,
|
|
||||||
adjustExternalHours,
|
|
||||||
adjustReason,
|
|
||||||
adjustWorkSummaryMutation,
|
|
||||||
calibrateServerOffset,
|
|
||||||
convexUserId,
|
|
||||||
ticket.id,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleExportPdf = useCallback(async () => {
|
const handleExportPdf = useCallback(async () => {
|
||||||
|
|
@ -1136,17 +1038,6 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : 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 ? (
|
{!editing ? (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -1179,73 +1070,19 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
|
||||||
actorId={convexUserId as Id<"users"> | null}
|
actorId={convexUserId as Id<"users"> | null}
|
||||||
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
requesterName={ticket.requester?.name ?? ticket.requester?.email ?? null}
|
||||||
agentName={agentName}
|
agentName={agentName}
|
||||||
|
workSummary={
|
||||||
|
effectiveWorkSummary
|
||||||
|
? {
|
||||||
|
totalWorkedMs: effectiveWorkSummary.totalWorkedMs,
|
||||||
|
internalWorkedMs: effectiveWorkSummary.internalWorkedMs,
|
||||||
|
externalWorkedMs: effectiveWorkSummary.externalWorkedMs,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
canAdjustTime={canAdjustWork && Boolean(effectiveWorkSummary)}
|
||||||
|
onWorkSummaryAdjusted={handleWorkSummaryAdjusted}
|
||||||
onSuccess={() => setStatus("RESOLVED")}
|
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="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -27,10 +27,8 @@ function escapeXml(value: string): string {
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, """)
|
||||||
.replace(/\u0008/g, "")
|
// remove invalid control characters (XML 1.0)
|
||||||
.replace(/\u000B/g, "")
|
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, "")
|
||||||
.replace(/\u000C/g, "")
|
|
||||||
.replace(/\u0000/g, "")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function columnRef(index: number): string {
|
function columnRef(index: number): string {
|
||||||
|
|
@ -60,7 +58,7 @@ function formatCell(value: unknown, colIndex: number, rowNumber: number, styleIn
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "boolean") {
|
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
|
let text: string
|
||||||
|
|
@ -123,17 +121,24 @@ function buildWorksheetXml(config: WorksheetConfig, styles: WorksheetStyles): st
|
||||||
sheetViews = `<sheetViews><sheetView workbookViewId="0">${pane}</sheetView></sheetViews>`
|
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 =
|
const autoFilter =
|
||||||
config.autoFilter && config.headers.length > 0 && totalRows > 1
|
config.autoFilter && config.headers.length > 0 && totalRows > 1
|
||||||
? `<autoFilter ref="A1:${columnRef(config.headers.length - 1)}${totalRows}"/>`
|
? `<autoFilter ref="A1:${usedRangeColumn}${totalRows}"/>`
|
||||||
: ""
|
: ""
|
||||||
|
|
||||||
return [
|
return [
|
||||||
XML_DECLARATION,
|
XML_DECLARATION,
|
||||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
||||||
|
dimensionXml,
|
||||||
sheetViews,
|
sheetViews,
|
||||||
colsXml,
|
|
||||||
' <sheetFormatPr defaultRowHeight="15"/>',
|
' <sheetFormatPr defaultRowHeight="15"/>',
|
||||||
|
colsXml,
|
||||||
" <sheetData>",
|
" <sheetData>",
|
||||||
rows.map((row) => ` ${row}`).join("\n"),
|
rows.map((row) => ` ${row}`).join("\n"),
|
||||||
" </sheetData>",
|
" </sheetData>",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue