feat: status + queue updates, filters e UI

- Status renomeados e cores (Em andamento azul, Pausado amarelo)
- Transições automáticas: iniciar=Em andamento, pausar=Pausado
- Fila padrão: Chamados ao criar ticket
- Admin/Empresas: renomeia ‘Slug’ → ‘Apelido’ + mensagens
- Dashboard: últimos tickets priorizam sem responsável (mais antigos)
- Tickets: filtro por responsável + salvar filtro por usuário
- Encerrar ticket: adiciona botão ‘Cancelar’
- Strings atualizadas (PDF, relatórios, badges)
This commit is contained in:
codex-bot 2025-10-20 14:57:22 -03:00
parent e91192a1f6
commit 5535ba81e6
19 changed files with 399 additions and 86 deletions

View file

@ -64,7 +64,7 @@ export async function GET(request: Request) {
// Status
const STATUS_PT: Record<string, string> = {
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Aguardando atendimento",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
}

View file

@ -72,6 +72,13 @@ export default function NewTicketPage() {
setAssigneeInitialized(true)
}, [assigneeInitialized, convexUserId])
// Default queue to "Chamados" if available
useEffect(() => {
if (queueName) return
const hasChamados = queueOptions.includes("Chamados")
if (hasChamados) setQueueName("Chamados")
}, [queueOptions, queueName])
async function submit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId || loading) return

View file

@ -228,7 +228,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
contractedHoursPerMonth: contractedHours,
}
if (!payload.name || !payload.slug) {
toast.error("Informe nome e slug válidos")
toast.error("Informe nome e apelido válidos")
return
}
startTransition(async () => {
@ -388,7 +388,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
/>
</div>
<div className="grid gap-2">
<Label htmlFor={slugId}>Slug</Label>
<Label htmlFor={slugId}>Apelido</Label>
<Input
id={slugId}
name="companySlug"
@ -541,7 +541,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Buscar por nome, slug ou domínio..."
placeholder="Buscar por nome, apelido ou domínio..."
className="h-9 pl-9"
/>
</div>

View file

@ -14,7 +14,7 @@ import { cn } from "@/lib/utils"
const statusLabel: Record<Ticket["status"], string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}

View file

@ -239,6 +239,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) {
const plainText = typeof window !== "undefined"
? new DOMParser().parseFromString(sanitizedHtml, "text/html").body.textContent?.replace(/\u00a0/g, " ").trim() ?? ""
: sanitizedHtml.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim()
const MAX_COMMENT_CHARS = 20000
if (plainText.length > MAX_COMMENT_CHARS) {
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`)
return
}
const hasMeaningfulText = plainText.length > 0
if (!hasMeaningfulText && attachments.length === 0) {
toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.")

View file

@ -102,6 +102,12 @@ export function PortalTicketForm() {
})
if (plainDescription.length > 0) {
const MAX_COMMENT_CHARS = 20000
if (plainDescription.length > MAX_COMMENT_CHARS) {
toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "portal-new-ticket" })
setIsSubmitting(false)
return
}
const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject)
const typedAttachments = attachments.map((file) => ({
@ -178,6 +184,7 @@ export function PortalTicketForm() {
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="Descreva rapidamente o que está acontecendo"
maxLength={600}
disabled={machineInactive || isSubmitting}
/>
</div>

View file

@ -24,7 +24,7 @@ const PRIORITY_LABELS: Record<string, string> = {
const STATUS_LABELS: Record<string, string> = {
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Aguardando atendimento",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
}

View file

@ -104,6 +104,17 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
setAssigneeInitialized(true)
}, [open, assigneeInitialized, convexUserId, form])
// Default queue to "Chamados" if available when opening
useEffect(() => {
if (!open) return
const current = form.getValues("queueName")
if (current) return
const hasChamados = queues.some((q) => q.name === "Chamados")
if (hasChamados) {
form.setValue("queueName", "Chamados", { shouldDirty: false, shouldTouch: false })
}
}, [open, queues, form])
const handleCategoryChange = (value: string) => {
const previous = form.getValues("categoryId") ?? ""
const next = value ?? ""
@ -166,6 +177,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
})
const summaryFallback = values.summary?.trim() ?? ""
const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback
const MAX_COMMENT_CHARS = 20000
const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim()
if (plainForLimit.length > MAX_COMMENT_CHARS) {
toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "new-ticket" })
setLoading(false)
return
}
if (attachments.length > 0 || bodyHtml.trim().length > 0) {
const typedAttachments = attachments.map((a) => ({
storageId: a.storageId as unknown as Id<"_storage">,
@ -254,9 +272,15 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin
<FieldLabel htmlFor="summary">Resumo</FieldLabel>
<textarea
id="summary"
className="min-h-[96px] w-full rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
className="min-h-[96px] w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-background p-3 text-sm shadow-sm focus-visible:border-[#00d6eb] focus-visible:outline-none"
maxLength={600}
{...form.register("summary")}
placeholder="Explique em poucas linhas o contexto do chamado."
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
/>
</Field>
<Field>

View file

@ -91,13 +91,17 @@ export function RecentTicketsPanel() {
const [enteringId, setEnteringId] = useState<string | null>(null)
const previousIdsRef = useRef<string[]>([])
const tickets = useMemo(
() =>
mapTicketsFromServerList((ticketsRaw ?? []) as unknown[])
.filter((ticket) => ticket.status !== "RESOLVED")
.slice(0, 6),
[ticketsRaw]
)
const tickets = useMemo(() => {
const all = mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]).filter((t) => t.status !== "RESOLVED")
// Unassigned first (no assignee), oldest first among unassigned; then the rest by updatedAt desc
const unassigned = all
.filter((t) => !t.assignee)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
const assigned = all
.filter((t) => !!t.assignee)
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
return [...unassigned, ...assigned].slice(0, 6)
}, [ticketsRaw])
useEffect(() => {
if (ticketsRaw === undefined) {

View file

@ -6,8 +6,8 @@ import { cn } from "@/lib/utils"
const statusStyles: Record<TicketStatus, { label: string; className: string }> = {
PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" },
AWAITING_ATTENDANCE: { label: "Em andamento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#fff3c4] text-[#7a5901]" },
RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" },
}

View file

@ -6,7 +6,6 @@ import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import type { TicketStatus } from "@/lib/schemas/ticket"
import { useAuth } from "@/lib/auth-client"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@ -15,25 +14,19 @@ import { RichTextEditor } from "@/components/ui/rich-text-editor"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { ChevronDown } from "lucide-react"
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"];
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
AWAITING_ATTENDANCE: { label: "Em andamento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
PAUSED: { label: "Pausado", badgeClass: "bg-[#fff3c4] text-[#7a5901]" },
RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" },
NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" },
OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" },
OPEN: { label: "Em andamento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" },
ON_HOLD: { label: "Pausado", badgeClass: "bg-[#fff3c4] text-[#7a5901]" },
};
const triggerClass =
"group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden"
const itemClass = "rounded-md px-2 py-2 text-sm text-neutral-800 transition hover:bg-slate-100 data-[state=checked]:bg-[#00e8ff]/15 data-[state=checked]:text-neutral-900 focus:bg-[#00e8ff]/10"
const baseBadgeClass =
"inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 px-3 text-sm font-semibold transition hover:border-slate-300"
@ -97,7 +90,6 @@ export function StatusSelect({
tenantId: string
requesterName?: string | null
}) {
const updateStatus = useMutation(api.tickets.updateStatus)
const { convexUserId } = useAuth()
const actorId = (convexUserId ?? null) as Id<"users"> | null
const [status, setStatus] = useState<TicketStatus>(value)
@ -107,48 +99,22 @@ export function StatusSelect({
setStatus(value)
}, [value])
const handleStatusChange = async (selected: string) => {
const next = selected as TicketStatus
if (next === "RESOLVED") {
setCloseDialogOpen(true)
return
}
const previous = status
setStatus(next)
toast.loading("Atualizando status...", { id: "status" })
try {
if (!actorId) {
throw new Error("missing user")
}
await updateStatus({ ticketId: ticketId as unknown as Id<"tickets">, status: next, actorId })
toast.success("Status alterado para " + (statusStyles[next]?.label ?? next) + ".", { id: "status" })
} catch (error) {
console.error(error)
setStatus(previous)
toast.error("Não foi possível atualizar o status.", { id: "status" })
}
}
return (
<>
<Select value={status} onValueChange={handleStatusChange}>
<SelectTrigger className={triggerClass} aria-label="Atualizar status">
<SelectValue asChild>
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
<ChevronDown className="size-3 text-current transition group-data-[state=open]:rotate-180" />
</Badge>
</SelectValue>
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option} value={option} className={itemClass}>
{statusStyles[option].label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="inline-flex items-center gap-2">
<Badge className={cn(baseBadgeClass, statusStyles[status]?.badgeClass ?? statusStyles.PENDING.badgeClass)}>
{statusStyles[status]?.label ?? statusStyles.PENDING.label}
</Badge>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setCloseDialogOpen(true)}
className="h-8 rounded-full border-slate-300 bg-white px-3 text-xs font-medium text-neutral-800"
>
Encerrar
</Button>
</div>
<CloseTicketDialog
open={closeDialogOpen}
onOpenChange={(open) => {
@ -303,6 +269,14 @@ function CloseTicketDialog({ open, onOpenChange, ticketId, tenantId, actorId, re
O comentário será público e ficará registrado no histórico do ticket.
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancelar
</Button>
<Button
type="button"
variant="outline"

View file

@ -129,6 +129,14 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
if (!convexUserId) return
// Enforce generous max length for comment plain text
const sanitized = sanitizeEditorHtml(body)
const plain = sanitized.replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim()
const MAX_COMMENT_CHARS = 20000
if (plain.length > MAX_COMMENT_CHARS) {
toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "comment" })
return
}
const now = new Date()
const selectedVisibility = canSeeInternalComments ? visibility : "PUBLIC"
const attachments = attachmentsToSend.map((item) => ({ ...item }))
@ -139,7 +147,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
id: `temp-${now.getTime()}`,
author: ticket.requester,
visibility: selectedVisibility,
body: sanitizeEditorHtml(body),
body: sanitized,
attachments: attachments.map((attachment) => ({
id: attachment.storageId,
name: attachment.name,

View file

@ -735,9 +735,21 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
/>
<textarea
value={summary}
onChange={(e) => setSummary(e.target.value)}
onChange={(e) => {
const el = e.currentTarget
// auto-resize height based on content
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
setSummary(e.target.value)
}}
onInput={(e) => {
const el = e.currentTarget
el.style.height = 'auto'
el.style.height = `${el.scrollHeight}px`
}}
rows={3}
className="w-full rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
maxLength={600}
className="w-full resize-none overflow-hidden rounded-lg border border-slate-300 bg-white p-3 text-sm text-neutral-800 shadow-sm"
placeholder="Adicione um resumo opcional"
/>
</div>

View file

@ -26,7 +26,7 @@ import {
const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "PENDING", label: "Pendente" },
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
{ value: "AWAITING_ATTENDANCE", label: "Em andamento" },
{ value: "PAUSED", label: "Pausado" },
{ value: "RESOLVED", label: "Resolvido" },
]
@ -67,6 +67,7 @@ export type TicketFiltersState = {
queue: string | null
channel: string | null
company: string | null
assigneeId: string | null
view: "active" | "completed"
}
@ -77,6 +78,7 @@ export const defaultTicketFilters: TicketFiltersState = {
queue: null,
channel: null,
company: null,
assigneeId: null,
view: "active",
}
@ -84,12 +86,13 @@ interface TicketsFiltersProps {
onChange?: (filters: TicketFiltersState) => void
queues?: QueueOption[]
companies?: string[]
assignees?: Array<{ id: string; name: string }>
initialState?: Partial<TicketFiltersState>
}
const ALL_VALUE = "ALL"
export function TicketsFilters({ onChange, queues = [], companies = [], initialState }: TicketsFiltersProps) {
export function TicketsFilters({ onChange, queues = [], companies = [], assignees = [], initialState }: TicketsFiltersProps) {
const mergedDefaults = useMemo(
() => ({
...defaultTicketFilters,
@ -119,9 +122,13 @@ export function TicketsFilters({ onChange, queues = [], companies = [], initialS
if (filters.queue) chips.push(`Fila: ${filters.queue}`)
if (filters.channel) chips.push(`Canal: ${filters.channel}`)
if (filters.company) chips.push(`Empresa: ${filters.company}`)
if (filters.assigneeId) {
const found = assignees.find((a) => a.id === filters.assigneeId)
chips.push(`Responsável: ${found?.name ?? filters.assigneeId}`)
}
if (!filters.status && filters.view === "completed") chips.push("Exibindo concluídos")
return chips
}, [filters])
}, [filters, assignees])
return (
<div className="flex flex-col gap-4">
@ -167,6 +174,22 @@ export function TicketsFilters({ onChange, queues = [], companies = [], initialS
</Select>
</div>
<div className="flex items-center gap-2">
<Select
value={filters.assigneeId ?? ALL_VALUE}
onValueChange={(value) => setPartial({ assigneeId: value === ALL_VALUE ? null : value })}
>
<SelectTrigger className="md:w-[220px]">
<SelectValue placeholder="Responsável" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE}>Todos os responsáveis</SelectItem>
{assignees.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.view}
onValueChange={(value) => setPartial({ view: value as TicketFiltersState["view"] })}

View file

@ -50,7 +50,7 @@ const tableRowClass =
const statusLabel: Record<TicketStatus, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
AWAITING_ATTENDANCE: "Em andamento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
}

View file

@ -39,6 +39,7 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
queuesEnabled ? api.queues.summary : "skip",
queuesEnabled ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as TicketQueueSummary[] | undefined
const agents = useQuery(api.users.listAgents, { tenantId }) as { _id: string; name: string }[] | undefined
const ticketsRaw = useQuery(
api.tickets.list,
convexUserId
@ -49,20 +50,66 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
priority: filters.priority ?? undefined,
channel: filters.channel ?? undefined,
queueId: undefined, // simplified: filter by queue name on client
assigneeId: filters.assigneeId ? (filters.assigneeId as unknown as Id<"users">) : undefined,
search: filters.search || undefined,
}
: "skip"
)
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const companies = useMemo(() => {
const set = new Set<string>()
for (const t of tickets) {
const name = ((t as unknown as { company?: { name?: string } })?.company?.name) as string | undefined
if (name) set.add(name)
const [companies, setCompanies] = useState<string[]>([])
useEffect(() => {
let aborted = false
async function loadCompanies() {
try {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json().catch(() => ({}))) as { companies?: Array<{ name: string }> }
const names = Array.isArray(json.companies) ? json.companies.map((c) => c.name).filter(Boolean) : []
if (!aborted) setCompanies(names.sort((a, b) => a.localeCompare(b, "pt-BR")))
} catch {
if (!aborted) setCompanies([])
}
}
return Array.from(set).sort((a, b) => a.localeCompare(b, "pt-BR"))
}, [tickets])
void loadCompanies()
return () => {
aborted = true
}
}, [])
// load saved filters as defaults per user
useEffect(() => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
const saved = localStorage.getItem(key)
if (saved) {
const parsed = JSON.parse(saved) as Partial<TicketFiltersState>
setFilters((prev) => ({ ...prev, ...parsed }))
}
} catch {
// ignore
}
}, [tenantId, convexUserId])
const handleSaveDefault = () => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
localStorage.setItem(key, JSON.stringify(filters))
} catch {
// ignore
}
}
const handleClearDefault = () => {
if (!convexUserId) return
try {
const key = `tickets:filters:${tenantId}:${String(convexUserId)}`
localStorage.removeItem(key)
} catch {
// ignore
}
}
const filteredTickets = useMemo(() => {
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
@ -92,8 +139,25 @@ export function TicketsView({ initialFilters }: TicketsViewProps = {}) {
onChange={setFilters}
queues={(queues ?? []).map((q) => q.name)}
companies={companies}
assignees={(agents ?? []).map((a) => ({ id: a._id, name: a.name }))}
initialState={mergedInitialFilters}
/>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={handleSaveDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-800 hover:bg-slate-50"
>
Salvar filtro como padrão
</button>
<button
type="button"
onClick={handleClearDefault}
className="rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-neutral-600 hover:bg-slate-50"
>
Limpar padrão
</button>
</div>
{ticketsRaw === undefined ? (
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-3">

View file

@ -12,6 +12,7 @@ export const TICKET_TIMELINE_LABELS: Record<string, string> = {
PRIORITY_CHANGED: "Prioridade alterada",
ATTACHMENT_REMOVED: "Anexo removido",
CATEGORY_CHANGED: "Categoria alterada",
REQUESTER_CHANGED: "Solicitante alterado",
MANAGER_NOTIFIED: "Gestor notificado",
VISIT_SCHEDULED: "Visita agendada",
CSAT_RECEIVED: "CSAT recebido",

View file

@ -195,7 +195,7 @@ const styles = StyleSheet.create({
const statusStyles: Record<string, { backgroundColor: string; color: string; label: string }> = {
PENDING: { backgroundColor: "#F1F5F9", color: "#0F172A", label: "Pendente" },
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Aguardando atendimento" },
AWAITING_ATTENDANCE: { backgroundColor: "#E0F2FE", color: "#0369A1", label: "Em andamento" },
PAUSED: { backgroundColor: "#FEF3C7", color: "#92400E", label: "Pausado" },
RESOLVED: { backgroundColor: "#DCFCE7", color: "#166534", label: "Resolvido" },
}